Skip to content
seals.deals

🦭 seals.deals · developer platform

The seals.deals API

A REST API for your CRM — all of it, not the contact list with the machinery held back. Twenty-eight objects behind one endpoint shape, over plain HTTPS, on a key that can never see more than the person who made it.

BASE https://crm.seals.deals/api/v1 AUTH Bearer <api key>

Introduction

Everything you can see in seals.deals, your integrations can reach over HTTP.

The API is organised around the objects you already work with — contacts, companies, deals, products, activities and quotes, and the machinery behind them: forms and their fields, sequences and their steps, email templates, automations, and the record of everything that was sent and opened. It speaks JSON, uses standard HTTP verbs, and returns predictable status codes. Every request runs inside the workspace that issued the key and is bounded by that key’s permissions, so a key can never see or touch more than its creator can.

  • Base URL — all endpoints live under https://crm.seals.deals/api/v1.
  • JSON everywhere — send Content-Type: application/json; every response is JSON.
  • Envelope — responses are wrapped as { "object": "…", "data": … }.
  • Identifiers are UUIDs; timestamps are ISO 8601 (UTC); money uses ISO 4217 currency codes.

What’s different here

Five things worth knowing before you start building against it.

One shape, 28 objects

Same path, same verbs, same envelope, same paging — for every object. Learn contacts and you have learned the other twenty-seven.

The whole product

Not the contact list with the machinery held back. Pipelines and stages, forms and their fields, sequences and their steps, automations and what they did, every email sent and whether it was opened.

A key never outranks its author

Its ceiling is the permission set of whoever created it. If they only see the records they own, so does the key — and its scopes narrow it further from there.

Deleting is its own permission

delete is a separate scope you opt into. A key with write can change anything it can reach and destroy none of it.

One key per integration

Make as many as you have systems, name them, and revoke one without breaking the rest. Nothing is shared between them.

◆
The API is part of the product, not a tier of it. There is no plan that unlocks it, no per-seat API add-on, and no request budget to buy back.

Quickstart

From zero to your first record in three steps.

Create an API key

In seals.deals go to Settings → API keys, create a key, and pick its scopes (read, write, delete). Copy the key — it’s shown once.

Send it as a Bearer token

Add Authorization: Bearer YOUR_API_KEY to every request.

Make a call

List your contacts to confirm it works, then start creating and updating records.

curl https://crm.seals.deals/api/v1/contacts?limit=3 \
  -H "Authorization: Bearer YOUR_API_KEY"

Authentication

One bearer token per integration, scoped and revocable.

Every request must carry an API key as a bearer token:

Authorization: Bearer YOUR_API_KEY

Keys are created in Settings → API keys and hashed at rest — we never store the raw key, so copy it when it’s shown. Requests without a valid, unrevoked key get 401.

Scopes

A key carries one or more scopes. A request that needs a scope the key lacks returns 403.

read

List and retrieve records. Required for every GET.

write

Create and update records — POST and PATCH.

delete

Permanently delete records via DELETE. Grant deliberately.

◆
Reads and writes are additionally bounded by the permissions of the person who created the key. If they can only see records they own, the key sees only those too — the API never widens access.

Requests & responses

Predictable shapes in, predictable shapes out.

Send JSON bodies with Content-Type: application/json. Successful responses are wrapped in an envelope naming the object and carrying the payload in data — an array for lists, a single object for one record.

{
  "object": "contacts",
  "data": [
    {
      "id": "b1e2c3d4-5f6a-4b8c-9d0e-1f2a3b4c5d6e",
      "first_name": "Ada",
      "last_name": "Lovelace",
      "email": "ada@analytical.co",
      "lifecycle_stage": "lead",
      "created_at": "2026-07-09T08:15:00Z"
    }
  ]
}

Pagination & search

List endpoints take four query parameters.

ParameterTypeDescription
limitintRows to return. Default 50, max 100.
offsetintRows to skip, for paging. Default 0.
qstringCase-insensitive search over the object’s key text fields (name, email, number…).
iduuidReturn a single record by id (same as the Retrieve endpoint).
# Page 2 of companies matching "acme", 25 per page
curl "https://crm.seals.deals/api/v1/companies?q=acme&limit=25&offset=25" \
  -H "Authorization: Bearer YOUR_API_KEY"

Errors

Standard HTTP status codes; a JSON body with an error message.

StatusMeaningWhen
200OKRead or update succeeded.
201CreatedA record was created.
400Bad requestInvalid JSON, a bad UUID, or a field that failed validation.
401UnauthorizedMissing, malformed, invalid, or revoked API key.
403ForbiddenThe key is missing the scope this call needs.
404Not foundUnknown object, or the object doesn’t support this verb.
413Payload too largeRequest body exceeds the size limit.
429Too many requestsRate limit exceeded — back off and retry.
500Server errorSomething went wrong on our side.
{ "error": "API key is missing the delete scope" }

Fair use

Not metered, not sold by the call — so please be reasonable with it.

There is no request budget attached to your plan, no per-seat allowance, and nothing to buy back when an import runs long. What we ask instead is that you page with limit/offset rather than firing many small calls in parallel, and that scheduled jobs run on a schedule rather than in a loop.

Handle 429 anyway. We may apply a per-key limit if a single integration starts affecting everyone else’s workspace, and a client that already backs off exponentially will not notice the day that happens.

Objects

What you can read, create, update, and delete.

ObjectReadCreateUpdateDeleteNotes
contactsGETPOSTPATCHDELPeople.
companiesGETPOSTPATCHDELOrganisations.
dealsGETPOSTPATCHDELOpportunities in a pipeline.
productsGETPOSTPATCHDELCatalogue line items.
activitiesGETPOSTPATCHDELTasks, notes, calls, meetings, emails.
quotesGETPOSTPATCHDELNumber auto-assigned on create.
quote_itemsGETPOSTPATCHDELLine items; scoped to their parent quote.
pipelinesGETPOSTPATCHDELDeleting one with deals returns 409.
stagesGETPOSTPATCHDELDeleting one with deals returns 409.
formsGETPOSTPATCHDELToken auto-assigned; delete cascades fields + submissions.
form_fieldsGETPOSTPATCHDELThe questions on a form. Listed by position, ascending.
form_submissionsGETPOSTPATCHDELInbound form submissions.
email_templatesGETPOSTPATCHDELPlain-text or designed. See body_format.
quote_templatesGETPOSTPATCHDELQuote layouts & defaults.
sequencesGETPOSTPATCHDELDelete cascades steps + enrolments.
sequence_stepsGETPOSTPATCHDELOrdered by position, ascending. template_id is an email template.
sequence_enrollmentsGETPOSTPATCHDELPuts a contact into a sequence. Creating an active one starts sending.
automationsGETPOSTPATCHDELTrigger, conditions, actions. Created switched off.
contractsGETPOSTPATCHDELDocuments.
contract_templatesGETPOSTPATCHDELStarting points for a contract.
document_blocksGETPOSTPATCHDELReusable clauses, by category.
content_blocksGETPOSTPATCHDELReusable snippets of copy.
currenciesGETPOSTPATCHDELOrg currencies & FX rates.
custom_propertiesGETPOSTPATCHDELDelete cascades stored values.
emailsGET———Every email sent, with opens and clicks.
quote_emailsGET———The same, for quotes you sent.
automation_runsGET———Every action an automation took, and whether it worked.
automation_enrollmentsGET———Which records an automation is currently working through. New ones are created only by running an automation.
◆
Some objects are read-only, and deliberately so. emails, quote_emails, automation_runs and automation_enrollments are the record of what actually happened — what was sent, what was opened, what an automation did. You can read and report on them; nothing can write them but the system that produced them. A POST, PATCH or DELETE to one returns 404. The one way to cause an enrolment is the run an automation action, which goes through the same guards the engine applies to a triggered enrolment.

Field reference

Writable fields for the create/update objects. Related ids (company_id, owner_id…) must belong to your workspace.

contacts

FieldTypeNotes
first_namestringAt least one of first_name / last_name / email is required.
last_namestring
emailstring
phonestring
job_titlestring
company_iduuidAssociates the contact with a company.
lifecycle_stagestringe.g. lead, customer. Defaults to lead.
lead_statusstringDefaults to new.
owner_iduuidDefaults to the key’s creator.
city, countrystring

deals

FieldTypeNotes
namestringrequired
amountnumber
currencystringISO 4217, e.g. USD.
pipeline_iduuidWhich pipeline the deal lives in.
stage_iduuidCurrent stage. Accepting a linked quote auto-advances this to Closed Won.
company_iduuid
close_datedateYYYY-MM-DD.
prioritystringlow · medium · high.
forecast_categorystring
sourcestring
owner_iduuid

products

FieldTypeNotes
namestringrequired
skustring
unit_pricenumber
currencystringISO 4217.
tax_ratenumberPercent, e.g. 20.
categorystring
activeboolean
image_urlstring

activities

FieldTypeNotes
typestringtask · note · call · meeting · email.
titlestring
bodystring
contact_id / company_id / deal_iduuidWhat the activity is about.
due_datetimestampFor tasks.
statusstringe.g. open, completed.
end_at, location, meeting_url—For meetings.

quotes & quote_items

FieldTypeNotes
quotes.titlestringNumber is auto-assigned if omitted.
quotes.deal_id / company_id / contact_iduuidAssociations.
quotes.currencystringISO 4217.
quotes.statusstringdraft · sent · accepted · rejected.
quote_items.quote_iduuidrequired parent quote (in your workspace).
quote_items.namestring
quote_items.quantitynumber
quote_items.unit_pricenumber
quote_items.discountnumberPercent.

email_templates

FieldTypeNotes
namestringrequired
subjectstringMay contain {{tokens}}, merged per recipient at send time.
bodystringProse when body_format is text; HTML when it is html.
body_formatstringtext · html. Defaults to text — set it, or a designed body is sent as plain words.
themeobjectThe look of a designed email: colours, web-safe font, width, logo. {} is a complete, valid theme.
owner_iduuidDefaults to the key’s creator.
◆
A designed template is stored as ordinary HTML and styled on the way out — colours and spacing are inlined, and the whole thing is wrapped in the card your theme describes. Send the body as semantic HTML (<p>, <h2>, <ul>) rather than a styled layout of your own.

form_fields

FieldTypeNotes
form_iduuidrequired the form this field belongs to.
labelstringWhat the visitor reads.
field_typestringtext · email · tel · textarea · select · checkbox …
positionintOrder on the form, ascending.
required, hiddenboolean
placeholder, help_text, default_valuestring
mapping_kindstringWhere the answer lands: standard (a built-in field), custom, or none.
property_keystringFor standard — e.g. firstName, email.
custom_property_iduuidFor custom — a custom_properties id.
optionsarrayChoices, for the pick-one and pick-many types.
conditionalobjectShow this field only when another answer matches.

sequence_steps

FieldTypeNotes
sequence_iduuidrequired the sequence this step belongs to.
template_iduuidAn email_templates id — not a quote template.
positionintOrder in the sequence, from 0.
delay_daysintDays to wait after the previous step before this one is due.

sequence_enrollments

FieldTypeNotes
sequence_iduuidrequired
contact_iduuidrequired must be a contact the key can edit.
statusstringactive · completed · unenrolled. Only active sends.
current_stepintWhich step is next. Starts at 0.
enrolled_byuuidSet for you — always the key’s creator, whose mailbox the sequence sends from. Ignored if you send it.
!
Enrolling sends real email. An enrolment created with status: "active" is picked up by the sequence runner and mail goes out to that contact on the schedule the steps describe. Opted-out contacts are always suppressed, and a step is never sent twice — but there is no undo on a message already sent. Create with unenrolled first if you are testing.

Document templates

Quote and contract templates are created and edited like any other object. What makes them different is that the document itself lives in a JSON column — and a malformed one is refused with the reason rather than stored and discovered later as a broken PDF.

quote_templates

FieldTypeNotes
namestringrequired
is_defaultbooleanThe template new quotes start from.
configobjectHow the document is laid out. Every key is optional; unknown keys are left alone.
config.accentstringHex colour for the title and headings.
config.docTitlestringe.g. QUOTATION.
config.intro, config.footerNotestringMay carry merge fields.
config.titleAlignstringleft or center.
config.show…booleanshowVat, showDiscount, showSku, showImages, showTerms, showBillTo, showSignature, showAmountInWords, showSenderBlock, showBillingAddress, showShipTo, showCustomerTaxNo, showBankDetails.
config.tableStylestringlined, striped or plain.
config.productPrintStylestringcompact or roomy.
config.sectionOrderstring[]Section keys in order, e.g. ["intro","items","notes","terms"], plus any custom section keys.
config.lineColumnsobjectColumn → column, fold (printed inside the product cell) or hide. Columns: headers, rowNumber, images, quantity, unitPrice, discount, vat, lineTotal, productCode, productDetail.
config.customSectionsobject[]{ key, title?, body, cover? }. body may carry merge fields; cover renders it as a full cover page.

contract_templates

FieldTypeNotes
namestringrequired
is_defaultboolean
bodyobject[]The document, in order. Each item is a block (below).
config.accentstringHex colour for headings.
body[].idstringrequired Any string, unique within the body.
body[].typestringrequired block (Markdown in content), quote (renders the linked quote), pagebreak, or image.
body[].titlestringHeading shown above the block.
body[].contentstringMarkdown, on block items. May carry merge fields.
body[].srcstringrequired on an image block — the full-page image URL.
body[].blockRefuuidThe document_blocks entry it was inserted from, kept for reference.

Merge fields

Written {{name}}, and resolved when the document is rendered against the record it is attached to. In a quote they work in the intro, the terms, the footer note and any custom section body; in a contract, in any block content. The two document kinds know different things about the world, so their vocabularies overlap without matching — a field from the other column is simply an unknown field here.

Available inFields
Bothcustomer_name, contact_name, contact_email, customer_address, customer_tax_no, quote_number, quote_total, subtotal, vat_total, valid_until, company_name, company_address, company_phone, company_vat, bank_details, date
Quotes onlyquote_title, grand_total, total_in_words, issue_date, expiry_date, status
Contracts onlycontact_phone, contact_job_title, deal_name, deal_amount, deal_close_date, owner_name, owner_email

An unknown field stays visible in the document as ⟦name⟧, so a typo — or a field meant for later — shows up in review rather than quietly emptying a clause.

curl -X POST "https://crm.seals.deals/api/v1/quote_templates" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Enterprise quote",
    "config": {
      "accent": "#0f172a",
      "docTitle": "QUOTATION",
      "titleAlign": "center",
      "showVat": true,
      "sectionOrder": ["intro", "items", "terms", "sla"],
      "lineColumns": { "discount": "fold", "images": "hide" },
      "customSections": [{ "key": "sla", "title": "Service levels", "body": "Response within 4h for {{customer_name}}." }]
    }
  }'

Editing replaces the keys you send. To add a block to a contract template, read its body, append your block, and PATCH the whole array back.

Import a PDF

POST/v1/contract_templates/import

Send a PDF and get a contract template back, one full-page image block per page, in page order — what the import dialog does in the app, for callers that have no browser. Requires the write scope. Send it as multipart/form-data with a file field, or as JSON with file_base64.

FieldTypeNotes
filefilerequired (multipart) The PDF itself. JSON callers send file_base64 instead.
namestringTemplate name. Defaults to the filename without .pdf.
pagesstringWhich pages, e.g. 1-3,5 or 4-. Every page by default. A page the file does not have is an error, not a silent skip.
is_defaultbooleanMake it the default contract template.

Up to 20 MB and 40 pages per import; pages render at the same resolution as the app. The reply is 201 with the created template, the pages imported, and the document’s total page count. The page images are stored in your workspace and referenced by URL, so the template behaves like any other — you can reorder blocks, add Markdown clauses between pages, or drop a page with a PATCH.

curl -X POST "https://crm.seals.deals/api/v1/contract_templates/import" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@master-services-agreement.pdf" \
  -F "name=Master services agreement" \
  -F "pages=1-6"

Quote defaults

GET/v1/quote_defaults
PATCH/v1/quote_defaults

The settings a new quote starts from — the terms, how long it stays valid, the numbering prefix, the bank details and the VAT treatment. A quote that sets its own terms uses those; where it sets none, this is what the customer reads. Reading needs the read scope, changing it write.

These are workspace-wide. Changing the default terms changes what every quote without its own terms says, including ones already sent that the customer has not opened yet — so it is worth saying what you are changing before you change it.

FieldTypeNotes
quote_termsstringThe default terms block. May carry merge fields. Send "" to clear it.
quote_validity_daysintegerHow long a new quote stays valid, 1–365.
quote_number_prefixstringPrefix for generated quote numbers, e.g. Q-.
quote_bank_detailsstringWhat {{bank_details}} resolves to, and the bank block when shown.
quote_show_product_imagesbooleanWhether product images print by default.
default_vat_ratenumberPercentage, 0–100.
default_vat_inclusivebooleanWhether entered prices already include VAT.
id, name, base_currencyread-onlyReturned for reference. Change these in Settings, not here.

Only the fields you send change. A field that is not on this list — anything to do with billing, Stripe, sending or retention — is refused by name with a 400 rather than ignored, so a call that aims at the wrong field tells you instead of quietly doing nothing.

curl -X PATCH "https://crm.seals.deals/api/v1/quote_defaults" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "quote_terms": "Payment due 30 days from invoice. Prepared for {{customer_name}}.",
    "quote_validity_days": 30
  }'

List records

GET/v1/{object}

Returns an array of records the key is allowed to see, newest first. Combine with limit, offset and q.

Records in the recycle bin are left out — someone deleted them in the app, and they can be restored there for 90 days. So are a binned quote’s items and emails, and activities whose linked records are all in the bin.

Objects that are an ordered list rather than a history come back in their own order instead: form_fields and sequence_steps are returned by position, ascending, so the first row is the first question on the form and the first step of the sequence.

curl "https://crm.seals.deals/api/v1/deals?limit=20" \
  -H "Authorization: Bearer YOUR_API_KEY"

Retrieve one record

GET/v1/{object}?id={uuid}

Fetch a single record by id. Returns data as an array with zero or one element. A record in the recycle bin comes back as zero elements, the same as an id that does not exist.

curl "https://crm.seals.deals/api/v1/companies?id=3f2a…" \
  -H "Authorization: Bearer YOUR_API_KEY"

Create a record

POST/v1/{object}

Send a JSON object of writable fields. Returns 201 with the created record. Requires the write scope. A link to a record in the recycle bin — a contact_id of a deleted contact, say — is refused with 400; restore the record first.

curl -X POST https://crm.seals.deals/api/v1/deals \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "VEEWER Premium — Acme",
    "amount": 12000,
    "currency": "USD",
    "company_id": "3f2a…",
    "pipeline_id": "c74d…",
    "stage_id": "248c…"
  }'

Update a record

PATCH/v1/{object}?id={uuid}

Partial update — send only the fields you want to change; everything else is left untouched. Requires the write scope. A record in the recycle bin returns 404, and a link to one returns 400.

curl -X PATCH "https://crm.seals.deals/api/v1/deals?id=f0b8…" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 15000, "stage_id": "9a1d…" }'

Delete a record

DELETE/v1/{object}?id={uuid}

Deletes the record. Requires the delete scope — keys without it get 403.

Most objects go to the recycle bin, exactly as a delete in the app does: gone from the API at once, restorable in the app for 90 days, then purged. The response says which happened — {"deleted": true, "id": "…", "recycle_bin": true, "restorable_until": "…"}.

Into the bin: contacts, companies, deals, quotes, contracts, quote_templates, contract_templates, document_blocks, content_blocks and email_templates. A binned contract also stops resolving on its public share link and can no longer be signed.

!
Everything else is deleted permanently. products, custom_properties, forms, automations and pipelines have no undo, and some deletes cascade to children — deleting a form removes its fields and submissions, a sequence its steps and enrolments, a custom property its stored values. A record already in the recycle bin returns 404. A pipeline or stage that still has deals returns 409 (reassign them first). Give the delete scope only to keys that truly need it.
curl -X DELETE "https://crm.seals.deals/api/v1/contacts?id=b1e2…" \
  -H "Authorization: Bearer YOUR_API_KEY"

# → 200 { "object": "contacts", "data": { "deleted": true, "id": "b1e2…" } }

Run an automation

POST/v1/automations/{id}/enroll

Enrols records in an enabled automation right now, by hand — the only way an automation whose trigger is “only when someone runs it by hand” ever runs, and a way to (re)run any other automation on records you choose. Requires the write scope.

The body names the records, or asks for every record that currently matches the automation’s conditions:

FieldTypeMeaning
record_idsuuid[]Up to 200 ids of the automation’s object type (contacts, companies, deals or quotes). Duplicates are ignored.
allbooleantrue = every record that matches the automation’s conditions right now, never-enrolled records first, up to 500 per call. Same as "record_ids": null. Must be explicit — an empty body is a 400, never a broad run by accident.

Every record goes through the same guards a triggered enrolment gets: the automation’s conditions, its re-enrolment rule, one live run per record, and at most 5 enrolments per record per day. Records outside the key creator’s edit scope are skipped, not enrolled. Instant steps (set a property, create a task, notify) happen inside the call; emails and delayed steps are queued and go out with the next scheduled run, usually within 15 minutes.

Response fieldMeaning
requestedRecords the call looked at, after de-duplication and the cap.
enrolledRecords that were enrolled and started.
skippedRecords that were not, with the breakdown in reasons: not_found, no_access, filters, already_enrolled, active, cap.
matching, truncatedOnly with "all": true: how many records match right now, and whether the per-call cap left some for another call. Call again to continue — it picks up never-enrolled records first.
◆
Status codes. 404 when the automation doesn’t exist in your workspace, 409 when it is switched off (enable it first), 403 when the key’s creator has no edit access to that kind of record. Every enrolment is written to automation_enrollments with enrolled_by set to the key’s creator, and shows up in automation_runs as an enroll row.
# Two specific contacts
curl -X POST "https://crm.seals.deals/api/v1/automations/161f…/enroll" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"record_ids":["b1e2…","c3d4…"]}'

# → 200 { "object": "automations", "data": { "automation_id": "161f…", "requested": 2, "enrolled": 1, "skipped": 1,
#          "reasons": { "not_found": 0, "no_access": 0, "filters": 1, "already_enrolled": 0, "active": 0, "cap": 0 } } }

# Every record that matches the automation's conditions
curl -X POST "https://crm.seals.deals/api/v1/automations/161f…/enroll" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"all":true}'

# → 200 { "object": "automations", "data": { …, "matching": 5, "requested": 5, "enrolled": 5, "skipped": 0, "truncated": false } }

Webhooks

Get notified when records change, instead of polling.

Register an endpoint under Settings → API & webhooks and subscribe to events. When a record is created, updated, or deleted, seals.deals sends a signed POST to your URL — so your systems stay in sync in real time without polling.

Events are <object>.created, <object>.updated, and <object>.deleted (e.g. deal.updated, contact.created).

Records that go to the recycle bin follow one rule. Going into it — deleted in the app or with DELETE — sends <object>.deleted; restoring it sends <object>.updated with the whole record. Nothing is sent while it sits in the bin, or when it is purged 90 days later. An activity whose linked records are all in the bin goes, and comes back, with them.

{
  "id": "d3f1…",                 // unique delivery id
  "event": "deal.updated",
  "data": { /* the full record */ },
  "sent_at": "2026-07-10T09:00:00Z"
}
◆
Verify every delivery. Compute HMAC-SHA256(rawBody, yourSecret) and compare it, hex-encoded, against the X-Seals-Signature header (after the sha256= prefix) before trusting a payload.