Browse documentation
Workspace & account

Developers

Create access tokens, send contacts to the API, and receive signed webhooks when contacts and deals change.

On this page

Use the Developers page to connect another system to this workspace. An access token lets you create or update contacts (and optionally add a deal or custom field values). Webhooks notify a URL you control when contacts or deals change. There is no separate sandbox: a successful request writes a real contact.

Open the Developers page#

Open Settings → Developers from the workspace settings sidebar (the item labelled Developers, with a key icon). The page has two tabs: Access tokens and Webhooks.

Only workspace owners and admins can create, revoke, or delete tokens, and create, pause, test, or delete webhooks. Members can open the page and look at what is already there.

Creating a token, creating a webhook, and sending a test also need two-factor authentication. Without it, Ariyel refuses with “This needs two-factor authentication. Set it up under Profile → Two-factor authentication, then try again.” Turn it on in Settings → Profile under Two-factor authentication (see Protect your sign-in), then try again.

Create an access token#

  1. On Access tokens, choose Create access token.
  2. Enter a Token name you will recognise months later (up to 120 characters), then choose Create token.
  3. Copy the token immediately with Copy, then choose Done. It is shown once. Ariyel stores only a hash, so a lost token cannot be recovered and must be replaced.

The list shows each token’s name, its ary_… prefix, the created date, and Last used (Never until the token is first used; then the date of its latest request that got past the rate limit, even if the body was then rejected). Revoke keeps the row, marks it revoked, and stops the token working; a revoked token can’t be switched back on. The delete (trash) icon removes the row, and the token stops working. Both ask you to confirm first (Revoke access token or Delete access token); Cancel or Escape keeps the token. Either way, contacts and deals the token already created are kept.

Tokens don’t expire and have no scopes: every token can create or update contacts (and deals) in this workspace, and you can create as many as you need. To retire one, revoke or delete it.

Send a contact to the API#

Send a POST to the contacts URL shown on the Developers page (on Access tokens, once you have a token). Copy that URL from the page rather than hard-coding it; it is currently https://lbebmwfmakoagipkbbbk.supabase.co/functions/v1/contacts.

Use headers Authorization: Bearer <token> and Content-Type: application/json. The JSON body must include email (validated, lowercased, at most 254 characters), phone, or both. You can also send name and company. Longer values are cut: name and company to 120 characters, phone to 40. With no name, first_name and last_name are joined with a space instead. The token selects the workspace; a workspace_id in the body is ignored.

Ariyel looks for an existing contact inside that workspace:

  • With a valid email, it matches by email only, even if phone is also sent.
  • Without a valid email, it matches by phone (an email that isn’t valid is ignored and not saved). Only the digits count, and an 11-digit number that starts with 1 drops that 1, so (609) 582-4394, 609-582-4394, and +16095824394 are the same number. A phone with no digits counts as not sent. If several contacts have the number, the most recently updated one is used.

If no contact matches, Ariyel creates one and sets status to the workspace’s first Contact status (default Lead). A new contact sent without a name is named from its email address (ada.example@… becomes Ada Example) or, with no email, Caller followed by the phone exactly as sent (Caller (609) 582-4394). In Ariyel’s lists and pickers such a contact shows just its formatted number, (609) 582-4394, until you give it a name. A contact created from a phone alone has no email, so a later request with an email doesn’t match it.

A matched contact is updated. After an email match, a sent name replaces the stored name, while an omitted or empty name leaves it unchanged; a non-empty phone or company replaces what is stored, and omitting them keeps the stored value. A phone match never overwrites what is stored: a sent name or company only fills in one the contact doesn’t have, and the stored phone keeps its formatting.

Requests for the same email or the same number that arrive at the same moment, such as one per ringing phone on a single call, are handled one at a time: only one of them creates the contact, and the others match it.

curl -X POST 'https://lbebmwfmakoagipkbbbk.supabase.co/functions/v1/contacts' \
  -H 'Authorization: Bearer ary_replace_with_your_token' \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","name":"Ada Example","phone":"+1-555-0100","company":"Example Ltd"}'

Add a deal or custom fields#

Include an optional deal object, { "name": "…", "stage": "…" }. Flat keys deal.name and deal.stage (or deal[name] and deal[stage]) also work, for Zapier-style tools. By default, every request that includes a deal creates a new deal linked to the contact. It does not update an existing deal, so sending the same payload again creates another deal.

To add a deal only for new contacts, set only_if_new to true in the deal ({ "stage": "…", "only_if_new": true }), or send the flat key deal.only_if_new or deal[only_if_new]. The strings "true" and "false" also work, in any case. The deal is then created only when this request created the contact. If the contact already existed, it is still updated, but no deal is added and the response omits deal_id. false, or leaving only_if_new out, keeps the default.

Deal name defaults to the contact’s name, then email, then Deal, and is cut to 120 characters. stage is matched case-insensitively against the workspace’s pipeline stage names; if nothing matches, the first stage is used. The deal starts at the top of that stage with a value of 0 and a close date 30 days after the request.

An optional custom object, { "Field name": "value" } (or flat keys such as custom.Field name), writes to Contacts fields you added (not built-in ones such as Status), matched by field name, case-insensitively. Unknown names are skipped silently. Values must be strings, except a multi-select may also be a JSON array of labels and a Number field may also be a JSON number; empty values and other types are skipped. A Number field takes a JSON number or a numeric string (such as "42" or "-3.5") and stores it as a number; anything else sent to it is skipped and the rest of the request is still saved. For a single-select, a value that matches an option id or option label (case-insensitive and trimmed) is stored as that option’s id; if nothing matches, the trimmed string is stored as sent. For a multi-select, send a JSON array of labels or one comma-separated string; each label matches like single-select; a whole string that exactly names one option (even with a comma) is that option. Duplicates collapse, and unmatched labels are kept as text and show as “Unknown option” in the table. The list replaces the field’s value. An empty list writes nothing. Other field types store the trimmed string. Fields you don’t send keep their values.

Deal and custom fields are read only from JSON. A request that cannot create the deal still saves the contact; the response then omits deal_id.

{
  "email": "ada@example.com",
  "name": "Ada Example",
  "deal": { "name": "Website rebuild", "stage": "Qualified" },
  "custom": { "Industry": "Education" }
}

A phone system can log each incoming call this way, for example Dialpad through Zapier. Every call from the same number finds the same contact, and only the first call adds a deal:

curl -X POST 'https://lbebmwfmakoagipkbbbk.supabase.co/functions/v1/contacts' \
  -H 'Authorization: Bearer ary_replace_with_your_token' \
  -H 'Content-Type: application/json' \
  -d '{"phone":"(609) 582-4394","name":"Grace Example","deal.stage":"Called In","deal.only_if_new":"true"}'

The first call from that number creates the contact and the deal:

{ "ok": true, "id": "<contact id>", "created": true, "matched_by": null, "deal_id": "<deal id>" }

A later call from +16095824394 matches the same contact and adds no deal:

{ "ok": true, "id": "<contact id>", "created": false, "matched_by": "phone" }

Understand the responses#

A successful write returns 200 with { "ok": true, "id": "<contact id>", "created": true|false, "matched_by": "email"|"phone"|null }. created is true only when this request created the contact. matched_by says how an existing contact was found, and is null when the contact was created. deal_id is included when a deal was created. custom_set (the number of custom fields written; 0 if no name matched) is included when the request carries at least one non-empty custom value. A request with a non-empty _gotcha field is treated as spam: it returns 200 with only { "ok": true } and saves nothing.

StatusJSON errorWhen
400email or phone requiredNo valid email and no phone with at least one digit, or a body that isn’t valid JSON.
401unauthorizedMissing, unknown, or revoked token.
403unavailableThe workspace is suspended.
405method not allowedAny method other than POST (an OPTIONS preflight is answered normally).
413too largeA body over 64 KB (65,536 bytes), whether or not a Content-Length header announces it.
429rate limitedMore than 60 requests in 10 minutes for that token.
500could not saveThe contact, or the custom field values sent with it, could not be saved. If only the custom values failed, the contact itself was saved.
503temporarily unavailableRetry later.

The rate limit is 60 requests per 10 minutes per token. The 10 minutes start with the first counted request. Only requests that get past the token, workspace, and Content-Length checks count (a 429 doesn’t), even if the body is then rejected, including a body that turns out to be over 64 KB.

Receive webhooks#

On the Webhooks tab, choose Create webhook (or Add webhook if you already have one). Enter an Endpoint URL that starts with https:// and uses a public domain name — IP addresses, ports, and internal hostnames are refused, and deliveries to a name that points at a private address fail — and pick Events from contact.created, contact.updated, contact.deleted, deal.created, deal.updated, deal.stage_changed, and deal.deleted. All seven are on by default; keep at least one. You can’t change a webhook’s URL or events later: create a new webhook and delete the old one.

The signing secret is shown once, under Signing secret — shown once in the dialog. Copy it before you choose Done. Until you leave the Webhooks tab or reload the page, the webhook’s Signing secret button shows it again with Copy. After that the button stays disabled, and the secret can’t be shown again or changed. For a new secret, create a new webhook and delete the old one.

Each webhook card shows its URL, events, how many deliveries it had in the last 90 days, counting failed deliveries and test pings (for example N deliveries in the last 90 days), the date it was added, an on/off switch (paused webhooks receive nothing), Signing secret, Send test (sends a ping event; at most 10 tests per 10 minutes), and a delete (trash) icon that asks you to confirm with Delete webhook (Cancel or Escape keeps it). Deleting stops events to that URL immediately, cancels deliveries still waiting to send, and deletes the webhook’s delivery history; it can’t be undone. Send test works only while the webhook is on; on a paused one it says This webhook is paused — turn it on to send a test. An 11th test inside 10 minutes says Too many test sends. Try again in a few minutes. Test event sent means the ping was queued.

Ariyel POSTs JSON { "data": { … }, "event": "contact.created", "sent_at": "<ISO 8601>", "workspace_id": "…" }. sent_at is when the event was queued; a retry resends the identical body. data is the record as stored. Contact events include id, name, email, phone, company, status, timestamps, custom field values (in custom, keyed by field id), and the rest of that row. Deal events include the deal as stored (id, name, stage id, value, linked contact id, timestamps, and the rest of that row). deleted events carry the record as it was before deletion. A ping carries data: { "message": "Test event from Ariyel", "webhook_id": "…" }.

deal.stage_changed fires when a deal moves to a different stage, and when a deal is created (its previous stage is then empty: previous_stage_id and previous_stage_name are null). It does not fire for other edits, or when cards are reordered within a stage. Deleting a stage moves its deals to the stage you choose, and each of those moves sends deal.stage_changed. data is the deal as stored, plus stage_name, previous_stage_id, previous_stage_name, event_id, custom_by_name (the deal’s custom field values keyed by field name instead of field id), and contact: the linked contact as contact events send it, plus its own custom_by_name, or null when the deal has none. event_id is the same for every webhook and every retry of one move, so use it to de-duplicate. deal.updated still fires on the same move. A webhook subscribed to both receives deal.created or deal.updated first, then deal.stage_changed. The deal’s stage_changed_at is when it moved.

Headers are Content-Type: application/json, X-Ariyel-Event (the event name), X-Ariyel-Signature (sha256=<hex>), X-Ariyel-Delivery-Id (unique per delivery, the same on every retry of it), and Idempotency-Key (webhook/<delivery id>).

Your endpoint must answer with a 2xx within 5 seconds. Redirects are not followed. A 5xx, 408, 409, 425, 429, a timeout, or a failed connection is retried, up to 5 attempts in total, with exponential backoff starting at 30 seconds (30 s, then 60 s, 2 min, 4 min). A Retry-After header is honoured when it asks for a longer wait. A redirect or any other 4xx is recorded as failed and not retried. Failures never pause the webhook.

Each webhook gets one delivery at a time, oldest first, so a delivery waiting to retry holds back the later ones for that webhook until it succeeds or runs out of attempts. Deliveries can still arrive more than once or, for changes saved at the same moment, out of order, so de-duplicate on X-Ariyel-Delivery-Id.

Verify the signature#

Compute HMAC-SHA256 over the exact raw request body bytes using the signing secret, hex-encode it, and compare in constant time with the value after sha256= in X-Ariyel-Signature. Hash the bytes you received, not a re-serialised JSON object.

import crypto from 'node:crypto';

function verify(rawBody, secret, signatureHeader) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const presented = String(signatureHeader || '').replace(/^sha256=/, '');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(presented, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Limits and good practice#

  • Use one token per integration so you can revoke it alone.
  • Never put the token in client-side code.
  • The API always writes a contact. There is no contact-only dry run, and no read, list, update-by-id, or delete endpoint. The contacts endpoint and webhooks are the whole public API today.
  • 60 requests per 10 minutes per token; 64 KB body limit.
  • Webhooks stop while the workspace is suspended; deliveries already queued are cancelled, not sent later.
  • A paused webhook drops events rather than queueing them. Only active webhooks are enqueued, and deliveries already queued when you pause one are cancelled.
  • A broken or failing webhook never stops a contact or deal from saving; events are queued and delivered after the change is saved.
Still have a question?Talk to us

Search documentation

Search all guides, or pick a place to start.