# Webhooks

Get a signed call on your server when emails are delivered, bounce, get replies or lose a subscriber.

A webhook endpoint is a URL on your server that Sendocado calls when something happens in a project: a reply arrives, an email bounces, someone unsubscribes.

## Add an endpoint

The URL must be https and on the public internet. Pick the event types it gets; the answer carries the signing secret.

```sh Add an endpoint
curl -X POST "https://sendocado.com/api/v1/projects/$PROJECT_ID/webhook-endpoints" \
  -H "Authorization: Bearer $SENDOCADO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://acme.example/webhooks/sendocado",
  "event_types": ["reply.received", "email.bounced"]
}'
```

## Events

| Type | When |
| --- | --- |
| `email.sent` | The provider accepted an email |
| `email.delivered` | The provider delivered it |
| `email.delivery_delayed` | Delivery is delayed and still being tried |
| `email.bounced` | It bounced permanently; the address is now blocked |
| `email.complained` | The recipient marked it as spam; the address is now blocked |
| `email.failed` | It could not be sent |
| `email.opened` | It was opened |
| `email.clicked` | A link in it was clicked |
| `reply.received` | A reply arrived |
| `contact.unsubscribed` | An address unsubscribed |

Every call is a POST with a JSON body `{ type, created_at, data }`:

```json email.bounced
{
  "type": "email.bounced",
  "created_at": 1790000240000,
  "data": {
    "send_id": "thjxjqi3ogz5kok16zv0mwufxbv932by",
    "project_id": "ujzde8gxd6ncf10epf91dhodzdoc9is0",
    "contact_id": "j8ht9lgmxg9edn581u33xtplpft75v2s",
    "to": "maya@example.com",
    "subject": "Welcome to Acme, Maya",
    "source": {
      "type": "campaign_step",
      "campaign_id": "h5dnsipzz5fk2z9ri19r0wyojfljooa5",
      "step_id": "j8gxbenyjqwx4hh5344tfjgvq4k7bn7x",
      "enrollment_id": "j3j4wj99ibag7i1mnbqns6puq80idw37"
    },
    "detail": "550 5.1.1 The email account that you tried to reach does not exist."
  }
}
```

```json reply.received
{
  "type": "reply.received",
  "created_at": 1790010800000,
  "data": {
    "id": "kdfy6spsc3lkr2aqxv9upctnwlavyf4r",
    "project_id": "ujzde8gxd6ncf10epf91dhodzdoc9is0",
    "send_id": "thjxjqi3ogz5kok16zv0mwufxbv932by",
    "contact_id": "j8ht9lgmxg9edn581u33xtplpft75v2s",
    "campaign_id": "h5dnsipzz5fk2z9ri19r0wyojfljooa5",
    "broadcast_id": null,
    "from_email": "maya@example.com",
    "from_name": "Maya Patel",
    "subject": "Re: Welcome to Acme, Maya",
    "preview": "Thanks! Quick question: can I import from a CSV?",
    "is_auto_reply": false,
    "is_unread": true,
    "created_at": 1790010800000,
    "text": "Thanks! Quick question: can I import from a CSV?\n\nMaya",
    "html_url": null,
    "attachments": []
  }
}
```

`email.opened` adds `user_agent`, and `email.clicked` adds `url` and `user_agent`. `contact.unsubscribed` carries `email`, `contact_id`, `send_id` and `source`.

## Verify the signature

Calls follow the [Standard Webhooks](https://www.standardwebhooks.com) spec. Each has three headers:

- `webhook-id`: the event id, the same on every retry. Use it to drop repeats.
- `webhook-timestamp`: when this attempt was sent, in seconds.
- `webhook-signature`: `v1,` and an HMAC-SHA256 signature of `<id>.<timestamp>.<body>` with your secret.

Check the signature against the raw body before you trust a call. With the `standardwebhooks` package for Node:

```ts Verify a call
import { Webhook } from 'standardwebhooks'

const webhook = new Webhook(process.env.SENDOCADO_WEBHOOK_SECRET)

export async function POST(request: Request) {
  const body = await request.text()
  const event = webhook.verify(body, {
    'webhook-id': request.headers.get('webhook-id') ?? '',
    'webhook-timestamp': request.headers.get('webhook-timestamp') ?? '',
    'webhook-signature': request.headers.get('webhook-signature') ?? '',
  })
  // event is { type, created_at, data }
  return new Response(null, { status: 204 })
}
```

Libraries for Python, Go, Ruby, PHP and more are listed on the Standard Webhooks site.

## Retries

- Answer with any 2xx status within 10 seconds. Anything else is a failed attempt.
- A failed event is tried again with growing waits, up to 12 attempts over about a day.
- After 5 events in a row fail every attempt, the endpoint is turned off and you get an email. Turn it back on with an update.
- Every attempt is listed in the endpoint’s deliveries for 30 days, and you can deliver any event again.

## Rotate the secret

Rotating gives a new secret that signs every attempt from then on, retries included, so switch your receiver to it at once.

See the [Webhooks reference](https://sendocado.com/docs/api/webhooks) for every route.
