> 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


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://help.ringtonic.app/guides/crm-api.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
