# Webhooks


# Webhooks

Add an HTTPS endpoint under **Settings → Developers → Webhook endpoints** and AutoUncle CoDriver sends it an event every time something happens. Choose event types, or none to receive every type, including types added later.

## Events

| Type | Fires when | `subject` |
|---|---|---|
| `lead.received` | A lead reached AutoUncle CoDriver (pushed, emailed or uploaded) | `led_…` |
| `lead.processed` | AutoUncle CoDriver read the lead and put it on an opportunity | `led_…` |
| `opportunity.created` | A new opportunity was opened | `opp_…` |
| `opportunity.updated` | Its details, vehicles, intents or handling state changed (`data.change` says which) | `opp_…` |
| `opportunity.assigned` | A salesperson was assigned, reassigned or unassigned | `opp_…` |
| `opportunity.closed` | It was closed: won, lost, disqualified, no response, other or expired | `opp_…` |
| `opportunity.reopened` | A closed opportunity was opened again | `opp_…` |
| `message.received` | The customer wrote (email, SMS, WhatsApp…) | `opp_…` |
| `message.sent` | AutoUncle CoDriver or a salesperson wrote to the customer | `opp_…` |
| `appointment.requested` | The customer asked for a visit without a time | `apt_…` |
| `appointment.booked` | An appointment was booked | `apt_…` |
| `appointment.rescheduled` | A booked appointment's time or items changed | `apt_…` |
| `appointment.confirmed` | The dealer confirmed an appointment | `apt_…` |
| `appointment.cancelled` | An appointment was cancelled or rejected | `apt_…` |

Each event's `data` has a JSON Schema at `https://codriver.autouncle.com/developers/schemas/v1/<type>.json`, also linked from the event's `dataschema`.

## The request

The body is a [CloudEvent](https://cloudevents.io) (structured JSON mode):

```json
{
  "specversion": "1.0",
  "id": "evt_019a1b2c-3d4e-7f5a-8b6c-7d8e9f0a1b2c",
  "source": "https://codriver.autouncle.com/organizations/org_019535d9-3df7-79fb-b466-fa907fa17f9e",
  "type": "opportunity.closed",
  "subject": "opp_019535d9-3df7-79fb-b466-fa907fa17f9e",
  "time": "2026-10-23T09:41:00Z",
  "datacontenttype": "application/json",
  "dataschema": "https://codriver.autouncle.com/developers/schemas/v1/opportunity.closed.json",
  "test": false,
  "data": {
    "opportunity": {
      "id": "opp_019535d9-3df7-79fb-b466-fa907fa17f9e",
      "status": "closed",
      "closed_reason": "won",
      "closed_at": "2026-10-23T09:41:00Z",
      "handling_state": "in_progress",
      "assigned_to": null,
      "business_unit": null,
      "customer": { "id": "cus_019535d9-3df7-79fb-b466-fa907fa17f9e", "name": "Anna Jensen", "email": "anna@example.com", "phone": null, "preferred_locale": "da" },
      "vehicles": [],
      "intents": [],
      "external_references": [ { "system": "my-crm", "id": "4711" } ],
      "created_at": "2026-10-20T08:00:00Z",
      "updated_at": "2026-10-23T09:41:00Z",
      "last_customer_activity_at": "2026-10-22T17:30:00Z",
      "test": false
    }
  }
}
```

The headers follow [Standard Webhooks](https://www.standardwebhooks.com):

- `webhook-id`: the event ID. Delivery is at least once: dedupe on it.
- `webhook-timestamp`: Unix seconds.
- `webhook-signature`: `v1,<base64 HMAC-SHA256 of "id.timestamp.body">` keyed with your endpoint's `whsec_` secret. During a secret rotation two signatures arrive, space-separated; accept either.

## Verify every request

Use a Standard Webhooks library with the endpoint's signing secret (shown once when you add the endpoint or rotate it). Verify against the **raw** request body, before parsing it, and reject timestamps more than five minutes old (the libraries do).

```javascript
// npm install standardwebhooks
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.CODRIVER_WEBHOOK_SECRET); // whsec_…

app.post("/codriver-webhook", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body, req.headers); // throws unless AutoUncle CoDriver signed it
  } catch {
    return res.status(400).end();
  }
  // claimEvent records the ID and returns false if it was already there, in one atomic
  // step (e.g. INSERT … ON CONFLICT DO NOTHING), so two deliveries of one event can't both pass.
  if (claimEvent(event.id)) enqueue(event); // do the work later
  res.status(204).end();
});
```

```python
# pip install standardwebhooks
import os

from standardwebhooks.webhooks import Webhook

wh = Webhook(os.environ["CODRIVER_WEBHOOK_SECRET"])  # whsec_…

@app.post("/codriver-webhook")
def codriver_webhook():
    try:
        event = wh.verify(request.get_data(), dict(request.headers))
    except Exception:
        return "", 400
    # claim_event records the ID atomically (e.g. INSERT … ON CONFLICT DO NOTHING) and
    # returns False if it was already there, so two deliveries of one event can't both pass.
    if claim_event(event["id"]):
        enqueue(event)
    return "", 204
```

## Answer fast, and what happens when you don't

Answer `2xx` within 15 seconds and do the work afterwards.

- `408`, `429`, `5xx`, timeouts and connection errors are retried after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h.
- Redirects (`3xx`) are not followed, and like other `4xx` they are not retried. `410 Gone` switches the endpoint off.
- Retries mean events can arrive out of order, and event IDs don't tell you the order. Use the ID only to dedupe. If you overwrite records with the snapshot, read the resource again with `GET` first: it always returns the current state.
- An endpoint that has accepted nothing for five days is switched off when an event finally fails; switch it on again under Settings → Developers.
- Missed something? Every event is also in the [events feed](/developers/events-feed) for 30 days, and you can resend any delivery from the endpoint's page.

Deliveries go to `https` addresses that resolve to the public internet only.
