🦭 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.
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.
Same path, same verbs, same envelope, same paging — for every object. Learn
contacts and you have learned the other twenty-seven.
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.
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.
delete is a separate scope you opt into. A key with write can
change anything it can reach and destroy none of it.
Make as many as you have systems, name them, and revoke one without breaking the rest. Nothing is shared between them.
Quickstart
From zero to your first record in three steps.
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.
Add Authorization: Bearer YOUR_API_KEY to every request.
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"
const res = await fetch("https://crm.seals.deals/api/v1/contacts?limit=3", {
headers: { "Authorization": `Bearer ${process.env.SEALS_API_KEY}` },
});
const { data } = await res.json();
console.log(data);
import requests
r = requests.get(
"https://crm.seals.deals/api/v1/contacts",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"limit": 3},
)
r.raise_for_status()
contacts = r.json()["data"]
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.
List and retrieve records. Required for every GET.
Create and update records — POST and PATCH.
Permanently delete records via DELETE. Grant deliberately.
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"
}
]
}
{
"object": "deals",
"data": {
"id": "f0b85efc-4e15-4d1d-93c0-ca8bb5450889",
"name": "VEEWER Premium — Acme",
"amount": 12000,
"currency": "USD",
"stage_id": "248c5509-b5a6-40a8-ad9e-020ac722989b"
}
}
Pagination & search
List endpoints take four query parameters.
| Parameter | Type | Description |
|---|---|---|
| limit | int | Rows to return. Default 50, max 100. |
| offset | int | Rows to skip, for paging. Default 0. |
| q | string | Case-insensitive search over the object’s key text fields (name, email, number…). |
| id | uuid | Return 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"
const params = new URLSearchParams({ q: "acme", limit: "25", offset: "25" });
const res = await fetch(`https://crm.seals.deals/api/v1/companies?${params}`, {
headers: { "Authorization": `Bearer ${key}` },
});
Errors
Standard HTTP status codes; a JSON body with an error message.
| Status | Meaning | When |
|---|---|---|
| 200 | OK | Read or update succeeded. |
| 201 | Created | A record was created. |
| 400 | Bad request | Invalid JSON, a bad UUID, or a field that failed validation. |
| 401 | Unauthorized | Missing, malformed, invalid, or revoked API key. |
| 403 | Forbidden | The key is missing the scope this call needs. |
| 404 | Not found | Unknown object, or the object doesn’t support this verb. |
| 413 | Payload too large | Request body exceeds the size limit. |
| 429 | Too many requests | Rate limit exceeded — back off and retry. |
| 500 | Server error | Something 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.
| Object | Read | Create | Update | Delete | Notes |
|---|---|---|---|---|---|
| contacts | GET | POST | PATCH | DEL | People. |
| companies | GET | POST | PATCH | DEL | Organisations. |
| deals | GET | POST | PATCH | DEL | Opportunities in a pipeline. |
| products | GET | POST | PATCH | DEL | Catalogue line items. |
| activities | GET | POST | PATCH | DEL | Tasks, notes, calls, meetings, emails. |
| quotes | GET | POST | PATCH | DEL | Number auto-assigned on create. |
| quote_items | GET | POST | PATCH | DEL | Line items; scoped to their parent quote. |
| pipelines | GET | POST | PATCH | DEL | Deleting one with deals returns 409. |
| stages | GET | POST | PATCH | DEL | Deleting one with deals returns 409. |
| forms | GET | POST | PATCH | DEL | Token auto-assigned; delete cascades fields + submissions. |
| form_fields | GET | POST | PATCH | DEL | The questions on a form. Listed by position, ascending. |
| form_submissions | GET | POST | PATCH | DEL | Inbound form submissions. |
| email_templates | GET | POST | PATCH | DEL | Plain-text or designed. See body_format. |
| quote_templates | GET | POST | PATCH | DEL | Quote layouts & defaults. |
| sequences | GET | POST | PATCH | DEL | Delete cascades steps + enrolments. |
| sequence_steps | GET | POST | PATCH | DEL | Ordered by position, ascending. template_id is an email template. |
| sequence_enrollments | GET | POST | PATCH | DEL | Puts a contact into a sequence. Creating an active one starts sending. |
| automations | GET | POST | PATCH | DEL | Trigger, conditions, actions. Created switched off. |
| contracts | GET | POST | PATCH | DEL | Documents. |
| contract_templates | GET | POST | PATCH | DEL | Starting points for a contract. |
| document_blocks | GET | POST | PATCH | DEL | Reusable clauses, by category. |
| content_blocks | GET | POST | PATCH | DEL | Reusable snippets of copy. |
| currencies | GET | POST | PATCH | DEL | Org currencies & FX rates. |
| custom_properties | GET | POST | PATCH | DEL | Delete cascades stored values. |
| emails | GET | — | — | — | Every email sent, with opens and clicks. |
| quote_emails | GET | — | — | — | The same, for quotes you sent. |
| automation_runs | GET | — | — | — | Every action an automation took, and whether it worked. |
| automation_enrollments | GET | — | — | — | Which records an automation is currently working through. New ones are created only by running an automation. |
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
| Field | Type | Notes |
|---|---|---|
| first_name | string | At least one of first_name / last_name / email is required. |
| last_name | string | |
| string | ||
| phone | string | |
| job_title | string | |
| company_id | uuid | Associates the contact with a company. |
| lifecycle_stage | string | e.g. lead, customer. Defaults to lead. |
| lead_status | string | Defaults to new. |
| owner_id | uuid | Defaults to the key’s creator. |
| city, country | string |
deals
| Field | Type | Notes |
|---|---|---|
| name | string | required |
| amount | number | |
| currency | string | ISO 4217, e.g. USD. |
| pipeline_id | uuid | Which pipeline the deal lives in. |
| stage_id | uuid | Current stage. Accepting a linked quote auto-advances this to Closed Won. |
| company_id | uuid | |
| close_date | date | YYYY-MM-DD. |
| priority | string | low · medium · high. |
| forecast_category | string | |
| source | string | |
| owner_id | uuid |
products
| Field | Type | Notes |
|---|---|---|
| name | string | required |
| sku | string | |
| unit_price | number | |
| currency | string | ISO 4217. |
| tax_rate | number | Percent, e.g. 20. |
| category | string | |
| active | boolean | |
| image_url | string |
activities
| Field | Type | Notes |
|---|---|---|
| type | string | task · note · call · meeting · email. |
| title | string | |
| body | string | |
| contact_id / company_id / deal_id | uuid | What the activity is about. |
| due_date | timestamp | For tasks. |
| status | string | e.g. open, completed. |
| end_at, location, meeting_url | — | For meetings. |
quotes & quote_items
| Field | Type | Notes |
|---|---|---|
| quotes.title | string | Number is auto-assigned if omitted. |
| quotes.deal_id / company_id / contact_id | uuid | Associations. |
| quotes.currency | string | ISO 4217. |
| quotes.status | string | draft · sent · accepted · rejected. |
| quote_items.quote_id | uuid | required parent quote (in your workspace). |
| quote_items.name | string | |
| quote_items.quantity | number | |
| quote_items.unit_price | number | |
| quote_items.discount | number | Percent. |
email_templates
| Field | Type | Notes |
|---|---|---|
| name | string | required |
| subject | string | May contain {{tokens}}, merged per recipient at send time. |
| body | string | Prose when body_format is text; HTML when it is html. |
| body_format | string | text · html. Defaults to text — set it, or a designed body is sent as plain words. |
| theme | object | The look of a designed email: colours, web-safe font, width, logo. {} is a complete, valid theme. |
| owner_id | uuid | Defaults to the key’s creator. |
theme describes. Send the body as semantic HTML
(<p>, <h2>, <ul>) rather than a styled
layout of your own.form_fields
| Field | Type | Notes |
|---|---|---|
| form_id | uuid | required the form this field belongs to. |
| label | string | What the visitor reads. |
| field_type | string | text · email · tel · textarea · select · checkbox … |
| position | int | Order on the form, ascending. |
| required, hidden | boolean | |
| placeholder, help_text, default_value | string | |
| mapping_kind | string | Where the answer lands: standard (a built-in field), custom, or none. |
| property_key | string | For standard — e.g. firstName, email. |
| custom_property_id | uuid | For custom — a custom_properties id. |
| options | array | Choices, for the pick-one and pick-many types. |
| conditional | object | Show this field only when another answer matches. |
sequence_steps
| Field | Type | Notes |
|---|---|---|
| sequence_id | uuid | required the sequence this step belongs to. |
| template_id | uuid | An email_templates id — not a quote template. |
| position | int | Order in the sequence, from 0. |
| delay_days | int | Days to wait after the previous step before this one is due. |
sequence_enrollments
| Field | Type | Notes |
|---|---|---|
| sequence_id | uuid | required |
| contact_id | uuid | required must be a contact the key can edit. |
| status | string | active · completed · unenrolled. Only active sends. |
| current_step | int | Which step is next. Starts at 0. |
| enrolled_by | uuid | Set for you — always the key’s creator, whose mailbox the sequence sends from. Ignored if you send it. |
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.List records
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"
const res = await fetch("https://crm.seals.deals/api/v1/deals?limit=20", {
headers: { "Authorization": `Bearer ${key}` },
});
const { data: deals } = await res.json();
r = requests.get(
"https://crm.seals.deals/api/v1/deals",
headers={"Authorization": f"Bearer {API_KEY}"},
params={"limit": 20},
)
deals = r.json()["data"]
Retrieve one record
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
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…"
}'
const res = await fetch("https://crm.seals.deals/api/v1/deals", {
method: "POST",
headers: {
"Authorization": `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "VEEWER Premium — Acme",
amount: 12000,
currency: "USD",
company_id: companyId,
pipeline_id: pipelineId,
stage_id: stageId,
}),
});
const { data: deal } = await res.json();
r = requests.post(
"https://crm.seals.deals/api/v1/deals",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"name": "VEEWER Premium — Acme",
"amount": 12000,
"currency": "USD",
"company_id": company_id,
},
)
deal = r.json()["data"]
Update a record
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…" }'
await fetch(`https://crm.seals.deals/api/v1/deals?id=${dealId}`, {
method: "PATCH",
headers: {
"Authorization": `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ amount: 15000, stage_id: wonStageId }),
});
Delete a record
Deletes the record. Requires the delete scope — keys without it get 403.
Contacts, companies, deals and quotes 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": "…"}.
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
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:
| Field | Type | Meaning |
|---|---|---|
| record_ids | uuid[] | Up to 200 ids of the automation’s object type (contacts, companies, deals or quotes). Duplicates are ignored. |
| all | boolean | true = 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 field | Meaning |
|---|---|
| requested | Records the call looked at, after de-duplication and the cap. |
| enrolled | Records that were enrolled and started. |
| skipped | Records that were not, with the breakdown in reasons: not_found, no_access, filters, already_enrolled, active, cap. |
| matching, truncated | Only 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. |
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"
}
X-Seals-Event: deal.updated
X-Seals-Delivery: d3f1… # matches payload.id
X-Seals-Timestamp: 1783934400
X-Seals-Signature: sha256=<hex> # HMAC-SHA256 of the raw body,
# keyed with your webhook secret
HMAC-SHA256(rawBody, yourSecret) and compare it, hex-encoded, against the
X-Seals-Signature header (after the sha256= prefix) before trusting a payload.