# Webhooks

## Outbound: changes, as they happen

Owners and admins add webhook URLs under Settings, Webhooks. Tandem POSTs each change to them as JSON once it's saved:

```json
{
  "id": "evt_1042",
  "type": "deal.won",
  "created_at": "2026-11-02T09:14:03.120Z",
  "workspace_id": "…",
  "actor": { "kind": "oauth_client", "id": "…", "on_behalf_of_user_id": "…" },
  "data": { "entity_type": "deal", "entity_id": "…", "before": { … }, "after": { … } }
}
```

**Event types.** Every change's own type, like `deal.created`, `deal.stage_changed`, `person.created`, `activity.logged` or `task.completed`, plus `deal.won` and `deal.lost` when a deal closes. Pick types when you add the webhook (`person.*` matches all of a record type's), or leave them all unticked for everything.

**Signatures.** Each delivery has a `Tandem-Signature: t=<unix seconds>,v1=<hex>` header. `v1` is the HMAC-SHA256 of `<t>.<raw body>` with the webhook's secret, shown once when you add it. Check it, and that `t` is recent, before trusting a delivery. `Tandem-Event` carries the type.

**Delivery.** A 2xx response counts as delivered. Errors and timeouts (10 seconds) are retried twice. After 50 failures in a row the webhook is turned off; turn it back on from the same page. URLs must be public `https` addresses; redirects aren't followed.

Webhooks are managed by people only, never by agents, so an agent can't send your data somewhere new.

## Inbound: leads from forms and automations

Create an inbound URL under Settings, Webhooks, and POST to it from a website form, Typeform, Zapier, Make or n8n. No headers needed: the secret is in the URL, so keep it private, and turn it off there if it leaks.

```bash
curl -X POST https://app.tandemcrm.app/hooks/inbound/crm_sk_… \
  -H "Content-Type: application/json" \
  -d '{"email": "jo@acme.com", "first_name": "Jo", "last_name": "Park", "company": "Acme", "message": "Can we get a demo?"}'
```

JSON and form-encoded bodies both work. Tandem adds or updates the company (by `domain`, `website` or a work email's domain, else by `company` name), the person (by `email`, with `name` or `first_name` and `last_name`, and `title`), and logs `message` or `note` as a note, with any other fields added to it. It answers `{ "received": true, "person_id", "company_id" }`. An inbound URL only does this: it can't be used as an API key.
