> For the complete documentation index, see [llms.txt](https://help.ringtonic.app/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://help.ringtonic.app/guides/crm-api.md).

# CRM Postback API

The CRM API lets external systems — your existing CRM, Zapier, Calendly, n8n, or custom integrations — push conversion events back into Ring Tonic. Every event you push appears on the matching [Contact](/guides/contacts.md)'s timeline, advances the funnel, and (when configured) uploads to Google Ads as an Enhanced Conversion.

{% hint style="info" %}
The CRM Postback API is **Agency plan** only. The general API key feature ([API](/guides/api.md)) is also Agency-gated.
{% endhint %}

***

### Who this is for

{% hint style="info" %}
**For developers** — jump to [Quickstart](#quickstart) and [Endpoints](#endpoints): request/response bodies, status codes, idempotency, rate limits, and ready-to-paste recipes for Calendly, HubSpot, and Zapier.

**For everyone else** — this connects an *outside* system (your CRM, scheduler, or automation tool) to Ring Tonic, so that when a lead books a call or a deal closes elsewhere, it lands on the contact's timeline and pushes the conversion to Google Ads automatically. It needs a developer — or a tool like Zapier — to set up once. If you just want to capture leads from your own website, the no-code [Form Submissions](/guides/form-submissions.md) is usually the better fit (see below).
{% endhint %}

### Do I need the API?

Reach for the Postback API only when the event happens in a system Ring Tonic can't already see.

| You want to…                                                                                     | Use                                             | Code needed?                |
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------- | --------------------------- |
| Capture leads from your own or a client's website form                                           | [Form Submissions](/guides/form-submissions.md) | No (tracking script)        |
| Carry attribution into the client's existing CRM                                                 | Form Attribution                                | No (tracking script)        |
| Push events from an *external* CRM, scheduler, or automation (HubSpot, Calendly, Zapier, custom) | **CRM Postback API** (this page)                | Yes (a developer or Zapier) |

### What you need before you start

1. **An Agency-plan workspace** — the CRM Postback API is Agency-only.
2. **An API key**, created at **Settings → API Keys → Create API Key** (copy it once — it's shown only once).
3. **The right abilities on that key:**
   * `workspace:<id>` — **exactly one**, the workspace this key may act on (required)
   * `crm:write` — to record conversions and create or update contacts
   * `crm:read` — to look up contacts or read a contact's event log
   * `crm:force` — only if you'll ever move a contact *backward* in the funnel
4. **The base URL:** `https://ringtonic.app/api/v1`

The full ability reference is in [Authentication](#authentication) below.

***

### Quickstart

```bash
curl -X POST https://ringtonic.app/api/v1/conversions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "phone": "+15551234567",
    "stage": "appointment_booked",
    "value_cents": 500000,
    "currency_code": "USD",
    "occurred_at": "2026-05-19T15:00:00Z"
  }'
```

That single call:

1. Finds the existing Contact with phone `+15551234567` in your workspace
2. Records a conversion event with stage `appointment_booked`
3. Advances the Contact's funnel stage (if forward)
4. Stores `deal_value` of `$5,000.00`
5. Fires `conversion.recorded` and `contact.stage_changed` webhooks
6. Queues a Google Ads Enhanced Conversion upload (if a mapping is configured)

***

### Authentication

All endpoints require a Sanctum personal access token. Generate one at **Settings** → **API Keys** → **Create API Key**.

<figure><img src="/files/rbbLsZ1lKe4uwqrqrheC" alt=""><figcaption><p>Creating a CRM API key under Settings → API Keys, with the workspace and crm abilities</p></figcaption></figure>

**Required header:**

```
Authorization: Bearer <your_api_key>
```

**Required token abilities** when creating the key:

| Ability          | Grants                                                                                   |
| ---------------- | ---------------------------------------------------------------------------------------- |
| `workspace:<id>` | Limits the token to one specific workspace — **exactly one workspace scope is required** |
| `crm:read`       | `GET /api/v1/contacts/match`, `GET /api/v1/contacts/{id}/events`                         |
| `crm:write`      | `POST /api/v1/contacts`, `PATCH /api/v1/contacts/{id}`, `POST /api/v1/conversions`       |
| `crm:force`      | Required when sending `force_stage: true` to move a contact backwards                    |

{% hint style="warning" %}
A CRM token must be scoped to **exactly one** workspace. Tokens without a `workspace:<id>` ability — or with multiple workspace abilities — receive 401 `token_missing_workspace_scope` / `token_multiple_workspace_scopes`. Mint a separate token per workspace.

Rule of thumb: **workspace-scope problems return `401`; a valid token that's simply missing a `crm:*` ability returns `403`.**
{% endhint %}

***

### Idempotency

Every state-changing endpoint accepts an optional `Idempotency-Key` header. Retries with the same key + same body return the original response and skip side effects.

```
Idempotency-Key: 7f4e9b2a-1c8d-4d6f-9e3b-2a1f8c4d7e9b
```

Recommended format: UUID v4 or any unique value your system can re-generate on retry.

* **Same key, same body** within 24h → cached response (200, not 201, on replay)
* **Same key, different body** within 24h → 409 `idempotency_key_reuse`
* **Same key on a previously-failed request** → cached failure response (mint a new key to retry against the same input)

Idempotency works equally for `POST /contacts` and `POST /conversions`. The window is 24h.

***

### Identity Matching

Several endpoints accept `phone`, `email`, and `external_id` as ways to identify a contact. Ring Tonic's matcher uses **priority order with conflict detection**:

```
1. external_id  (your system's contact ID — most reliable when set)
2. phone        (normalized to E.164 against the workspace's country)
3. email        (case-folded)
```

The matcher tries the highest-priority supplied identifier first. **If a lower-priority identifier points to a different contact**, the request is rejected with 422 `conflicting_identifiers` and a `candidates` array — this prevents a stale `email` from silently routing a conversion to the wrong contact.

**Resolving conflicts:** add `target_contact_id` to your request body to tell the matcher exactly which contact you mean. Hint identifiers are still soft-validated (a mismatch returns 422 `target_identifier_mismatch`), but the named contact is used.

```json
{
  "target_contact_id": 1284,
  "phone": "+15551234567",
  "stage": "won",
  "value_cents": 1200000
}
```

***

### Endpoints

#### `POST /api/v1/conversions` — record a conversion event

The primary postback endpoint. Use this whenever an external event has occurred that should advance the funnel: appointment booked, proposal sent, deal won, etc.

**Request body:**

```json
{
  "phone": "+15551234567",
  "email": "jane@example.com",
  "external_id": "hs-contact-12345",
  "stage": "appointment_booked",
  "value_cents": 500000,
  "currency_code": "USD",
  "occurred_at": "2026-05-19T15:00:00Z",
  "force_stage": false,
  "create_if_missing": false,
  "target_contact_id": null,
  "custom_fields": {
    "appointment_at": "2026-05-25T10:00:00Z"
  },
  "external_event_id": "calendly-evt-7d3"
}
```

**Required fields:**

* At least one identifier — `phone`, `email`, `external_id`, OR `target_contact_id`
* `stage` — any value from the Contact stages enum (`new`, `contacted`, `form_submitted`, `qualified`, `appointment_booked`, `proposal_sent`, `won`, `customer`, `lost`, `unqualified`)

**Optional fields:**

| Field               | Purpose                                                                                                                                        |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `value_cents`       | Deal value in cents. Required when `stage` is `won` or `proposal_sent` (or send `inherit_value: true` if the contact already has one set)      |
| `inherit_value`     | `true` to satisfy the `won` / `proposal_sent` value requirement by reusing the contact's existing deal value, instead of sending `value_cents` |
| `currency_code`     | ISO 4217 — defaults to workspace currency                                                                                                      |
| `occurred_at`       | When the event happened in your system (defaults to server time)                                                                               |
| `force_stage`       | `true` to allow a backward stage move. Requires `crm:force` token ability                                                                      |
| `create_if_missing` | `true` to create a new Contact when no match is found (otherwise 404)                                                                          |
| `target_contact_id` | Skip the matcher and use this contact id directly                                                                                              |
| `custom_fields`     | Map of `key: value` matching workspace custom field schema                                                                                     |
| `external_event_id` | Your system's identifier for this specific event (useful for support cross-referencing)                                                        |

**Response 201 (new event):**

```json
{
  "conversion_event_id": 88421,
  "contact_id": 1284,
  "contact_external_id": "hs-contact-12345",
  "previous_stage": "qualified",
  "requested_stage": "appointment_booked",
  "current_stage": "appointment_booked",
  "current_value_cents": 500000,
  "currency_code": "USD",
  "applied_stage_change": true,
  "google_ads_upload_status": "queued"
}
```

**Response 200** — idempotent replay (same body as the original 201).

**Response when stage was not applied** (e.g. backward move without force):

```json
{
  "conversion_event_id": 88422,
  "contact_id": 1284,
  "previous_stage": "won",
  "requested_stage": "qualified",
  "current_stage": "won",
  "applied_stage_change": false,
  "google_ads_upload_status": "skipped_no_stage_change",
  "ignore_reason": "backward_move_without_force"
}
```

In the no-op case the event still appears in the contact's timeline as audit history; `conversion.recorded` fires but `contact.stage_changed` does NOT.

***

#### `POST /api/v1/contacts` — create or upsert a contact

Use when you want to create a contact explicitly (without sending a conversion event yet).

**Request body:**

```json
{
  "external_id": "hs-contact-12345",
  "phone": "+15551234567",
  "email": "jane@example.com",
  "name": "Jane Doe",
  "company": "Acme Inc",
  "custom_fields": {
    "budget": 25000,
    "property_type": "condo"
  }
}
```

**Behavior:**

* **No existing contact** → creates one and returns `201`
* **Existing contact matched by `external_id`** → returns `409 external_id_exists` with the existing `contact_id`
* **Existing contact matched uniquely by phone or email** → returns `200` with the existing contact (idempotent on identifier match)
* **Multiple matches** → `422 ambiguous_match` with `candidates` array
* **Soft-deleted contact matches** → restored and returned with `"restored": true`

Phone numbers are normalized to E.164. `custom_fields` are validated against your workspace schema; unknown keys are rejected unless you pass `?allow_unknown_fields=true` (which stashes them under a `_unmapped` sub-key).

***

#### `PATCH /api/v1/contacts/{id}` — update a contact

Partial update. Body accepts any subset of the create-contact fields.

```bash
curl -X PATCH https://ringtonic.app/api/v1/contacts/1284 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jane Doe-Smith",
    "company": "Acme Holdings",
    "custom_fields": { "budget": 35000 }
  }'
```

You can also move the contact's stage here by sending `lead_status`. Note the field name differs by endpoint: `POST /conversions` uses `stage`, while this endpoint uses `lead_status`. The same forward-only rules apply, and a conversion event is recorded with source `postback`.

***

#### `GET /api/v1/contacts/match` — diagnostic lookup

Returns the contact the conversion endpoint would resolve for the given identifiers. Useful when wiring up Zapier or n8n to test your match logic before going live.

```bash
curl "https://ringtonic.app/api/v1/contacts/match?phone=%2B15551234567&email=jane@example.com" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Responses:**

* `200 { "match": { "id": 1284, ... }, "matched_by": "phone" }` — unique match
* `404 { "match": null }` — no match
* `422 { "error": "ambiguous_match", "candidates": [ ... ] }` — multiple contacts matched, send `target_contact_id` on your conversion call to pick one

***

#### `GET /api/v1/contacts/{id}/events` — conversion event log

Returns the conversion-event timeline for one contact — every stage change, with where it came from, its value, and when it happened. Cursor-paginated, 50 per page; follow `next_cursor` for older events.

```bash
curl "https://ringtonic.app/api/v1/contacts/1284/events" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response 200:**

```json
{
  "data": [
    {
      "id": 88421,
      "contact_id": 1284,
      "from_stage": "qualified",
      "to_stage": "appointment_booked",
      "source": "postback",
      "applied_stage_change": true,
      "value_cents": 500000,
      "currency_code": "USD",
      "external_event_id": "calendly-evt-7d3",
      "occurred_at": "2026-05-19T15:00:00+00:00"
    }
  ],
  "next_cursor": null
}
```

Use it to sync a contact's funnel history back to your CRM.

***

### Status Codes

| Code  | Meaning                                                                                            |
| ----- | -------------------------------------------------------------------------------------------------- |
| `200` | Idempotent replay, or unique-identifier match returning existing contact                           |
| `201` | New event recorded / new contact created                                                           |
| `400` | Malformed body (invalid JSON, missing required structure)                                          |
| `401` | Token missing, invalid, or lacks `workspace:<id>` scope                                            |
| `403` | Workspace not on Agency plan, or token lacks `crm:write` / `crm:force`                             |
| `404` | No matching contact (and `create_if_missing=false`), or contact in another workspace               |
| `409` | `external_id_exists` (POST /contacts), or `Idempotency-Key` reused with a different body           |
| `422` | Validation failed, or `ambiguous_match` / `conflicting_identifiers` / `target_identifier_mismatch` |
| `429` | Rate limit (see below)                                                                             |

**Error responses** carry a JSON body with an `error` code (the names used in the rows above) plus any useful context:

```json
{
  "error": "ambiguous_match",
  "candidates": [1284, 1290]
}
```

***

### Rate Limits

| Endpoint                           | Limit                             |
| ---------------------------------- | --------------------------------- |
| `POST /api/v1/conversions`         | 120 requests / minute / workspace |
| `POST /api/v1/contacts`            | 60 requests / minute / workspace  |
| `PATCH /api/v1/contacts/{id}`      | 120 requests / minute / workspace |
| `GET /api/v1/contacts/match`       | 60 requests / minute / workspace  |
| `GET /api/v1/contacts/{id}/events` | 60 requests / minute / workspace  |

All limits are keyed per workspace. 429 responses include a `Retry-After` header — back off and retry after the window resets. (Idempotent replays still pass through the limiter, so count them in your budget.)

***

### Webhook Companions

Every postback fires one or more outbound webhooks (see [Webhooks](/guides/webhooks.md)):

| Webhook event           | Fires on                                                       |
| ----------------------- | -------------------------------------------------------------- |
| `conversion.recorded`   | Every accepted conversion event (including no-op audit events) |
| `contact.stage_changed` | Only when the contact's stage actually moved                   |
| `form.submitted`        | Only on form-capture beacons (not on postback API calls)       |

The ordering inside a transaction is deterministic: `form.submitted` → `conversion.recorded` → `contact.stage_changed`.

***

### Integration Recipes

<details>

<summary>Calendly: appointment booked</summary>

In Calendly's webhook settings, point at `https://ringtonic.app/api/v1/conversions` with body:

```json
{
  "email": "{{ invitee.email }}",
  "phone": "{{ invitee.questions_and_answers[0].answer }}",
  "stage": "appointment_booked",
  "occurred_at": "{{ event.start_time }}",
  "external_event_id": "calendly-{{ event.uuid }}",
  "create_if_missing": true,
  "custom_fields": {
    "appointment_at": "{{ event.start_time }}"
  }
}
```

Use `external_event_id` as your `Idempotency-Key` so Calendly's retry-on-failure doesn't double-book the funnel.

</details>

<details>

<summary>HubSpot: deal closed-won</summary>

Set up a workflow that fires when a deal stage = "Closed Won" and call:

```json
{
  "external_id": "{{ contact.hs_object_id }}",
  "stage": "won",
  "value_cents": {{ deal.amount * 100 }},
  "currency_code": "{{ deal.deal_currency_code }}",
  "occurred_at": "{{ deal.closedate }}",
  "external_event_id": "hubspot-deal-{{ deal.hs_object_id }}"
}
```

The `external_id` matched on the contact's HubSpot ID gives you stable identity across CRM updates.

</details>

<details>

<summary>Zapier: form-fill in a non-Ring-Tonic form</summary>

Use a Zap with Code by Zapier to generate an Idempotency-Key (UUID), then POST to `/api/v1/conversions` with:

```json
{
  "phone": "{{ form_phone }}",
  "email": "{{ form_email }}",
  "stage": "form_submitted",
  "create_if_missing": true,
  "external_event_id": "zapier-{{ zap_id }}-{{ timestamp }}"
}
```

For Ring Tonic-hosted tracking, use [Form Submissions](/guides/form-submissions.md) instead — it's faster and free of Zapier latency.

</details>

***

### Common Questions

<details>

<summary>Do I need a separate API key per integration?</summary>

It's a good idea — separate keys can be revoked independently if one integration is compromised. Each key needs the same `workspace:<id>` ability for the target workspace.

</details>

<details>

<summary>What's the difference between `external_id` and `target_contact_id`?</summary>

`external_id` is *your* system's stable identifier for the contact (e.g. HubSpot's `hs_object_id`). It's stored on the Contact and used for future matching. `target_contact_id` is Ring Tonic's internal contact ID — useful as an escape hatch when the matcher returns `conflicting_identifiers` and you need to disambiguate.

</details>

<details>

<summary>Can I push the same event twice without dedup?</summary>

Yes — omit `Idempotency-Key`. Each call creates a new `ConversionEvent`. The contact still only advances on the first forward-stage transition; subsequent events are logged as `(ignored)` no-ops. Most integrators want idempotency on; the option to skip it is there for systems that genuinely need a separate event per call.

</details>

<details>

<summary>How long are idempotency records kept?</summary>

24 hours from the original request. After that the key is reusable.

</details>

<details>

<summary>Why am I getting 401 even though my key is correct?</summary>

The most common cause is a missing or incorrect workspace ability. CRM endpoints require `workspace:<id>` matching the workspace you want to act on. Re-create the key under **Settings** → **API Keys** with explicit `workspace:<id>`, `crm:write` (and `crm:force` if needed). Legacy un-scoped keys created before the CRM API shipped will fail closed on these routes with an upgrade hint.

</details>

***

### Related Guides

* [API](/guides/api.md) — general API access (keys, GeoData endpoint)
* [Contacts](/guides/contacts.md) — pipeline view of the data this API writes
* [Form Submissions](/guides/form-submissions.md) — Ring Tonic's own no-code form capture
* [Webhooks](/guides/webhooks.md) — subscribe to events emitted by postbacks
