# Webhooks

> Outbound webhook delivery from Caramel to your app — coming soon. Today, use polling against the available API tools.

> **Coming soon** Outbound webhooks are not live in `v1.0.0`. The infrastructure is designed and partially implemented; delivery to builder-registered URLs is not yet enabled. This page describes what's coming and the polling workarounds you need today.

## What to do today

Until outbound webhooks ship, observe state changes by polling the existing tools.

### Form submissions

Poll `caramel.v1.form.list` periodically. Each form's response includes `submission_count`. Compare against your last-seen count to detect new submissions.

For low-volume forms, polling every 1–5 minutes is sufficient. For high-volume forms, watch your [rate limit](rate-limits) bucket — 600 requests per minute per token leaves plenty of headroom.

> **Tip** The complete submission detail tool (per-submission field values) is planned for a future release. Until then, configure the form's notification email in the **Forms** builder to forward submissions out-of-band.

### Campaign state changes

Poll `list_campaigns` with a `status` filter. Most state changes are driven by your own calls (`generate_campaign`, `deploy_campaign`, `pause_campaign`), so you usually know the current state without polling. Poll only when an out-of-band action — such as a business owner pausing a campaign in the Caramel dashboard — might have occurred.

### Message delivery

Today there is no public API for per-message delivery status. Check the **Omnichannel → Analytics** dashboard in the app for delivery rates and bounce counts.

## What's coming

The details below describe the planned webhook contract. Design your handler now — the signature scheme and event shapes will not change before launch.

### Subscribing to events

```bash
# (Not yet live)
curl -X POST https://gateway.caramelme.com/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "business_id":  "bus_01h7m9k0aabcdefghj1234567",
    "url":          "https://yourapp.com/hooks/caramel",
    "events":       ["form.submitted", "campaign.deployed", "message.bounced"],
    "description":  "Production webhook"
  }'
```

The response includes a `signing_secret` used to verify deliveries. Store it securely — it is returned only once at creation. If you lose it, rotate via `PATCH /v1/webhooks/:id/rotate`.

```json
{
  "webhook_id":     "whk_01h7m9k0aabcdefghj1234567",
  "url":            "https://yourapp.com/hooks/caramel",
  "events":         ["form.submitted", "campaign.deployed", "message.bounced"],
  "signing_secret": "whsec_REPLACE_WITH_YOUR_SECRET",
  "created_at":     "2026-06-06T14:23:11Z"
}
```

### Event delivery shape

Every event is delivered as a POST to your registered URL:

```http
POST /hooks/caramel HTTP/1.1
Content-Type: application/json
x-caramel-event:     form.submitted
x-caramel-delivery:  dlv_01h7m9k0aabcdefghj1234567
x-caramel-timestamp: 1717684350
x-caramel-signature: t=1717684350,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{
  "id":          "evt_01h7m9k0aabcdefghj1234567",
  "type":        "form.submitted",
  "created_at":  "2026-06-06T14:23:11Z",
  "business_id": "bus_01h7m9k0aabcdefghj1234567",
  "data": {
    "submission_id": "sub_01h7m9k0aabcdefghj1234567",
    "form_id":       "frm_01h7m9k0aabcdefghj1234567",
    "contact_id":    "con_01h7m9k0aabcdefghj1234567",
    "fields":        { "email": "jane@example.com", "first_name": "Jane" },
    "source_url":    "https://yourapp.com/signup"
  }
}
```

Your endpoint must return `2xx` within 5 seconds. Non-2xx responses and timeouts trigger retries.

### Retry policy

| Attempt | Delay before retry |
|---|---|
| 1 (initial) | — |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 (final) | 12 hours |

After 7 failures the event moves to a dead-letter queue. List undelivered events via `GET /v1/webhooks/:id/failed` and replay individual ones via `POST /v1/webhooks/deliveries/:id/replay`.

### Signature verification

Every delivery has an `x-caramel-signature` header in the format:

```
t=<timestamp>,v1=<hmac_sha256(timestamp + "." + raw_body, signing_secret)>
```

Verify in your handler before trusting the payload:

```js
// Node.js
import crypto from 'node:crypto';

function verifyCaramelSignature(req, signingSecret) {
  const header = req.headers['x-caramel-signature'];
  if (!header) return false;

  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const { t: timestamp, v1: signature } = parts;
  if (!timestamp || !signature) return false;

  // Reject replays older than 5 minutes
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', signingSecret)
    .update(timestamp + '.' + req.rawBody)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
```

```py
# Python
import hmac, hashlib, time

def verify_caramel_signature(header: str, raw_body: bytes, secret: str) -> bool:
    parts = dict(p.split('=') for p in header.split(','))
    timestamp, signature = parts.get('t'), parts.get('v1')
    if not timestamp or not signature:
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(
        secret.encode(),
        f'{timestamp}.{raw_body.decode()}'.encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

Use constant-time comparison (`timingSafeEqual`, `compare_digest`) to prevent timing attacks against the HMAC.

### Idempotency

Each event has a unique `id` (`evt_…`). Caramel may retry deliveries, so the same `id` can arrive more than once. Treat your handler as idempotent on event `id`:

```js
// Use your database, not an in-memory Set, in production
const seen = new Set();

app.post('/hooks/caramel', (req, res) => {
  const event = JSON.parse(req.body);
  if (seen.has(event.id)) return res.status(200).end();
  seen.add(event.id);
  handle(event);
  res.status(200).end();
});
```

The `x-caramel-delivery` header (`dlv_…`) identifies the delivery attempt, distinct from the event ID.

### Planned event catalog

| Event | When fired |
|---|---|
| `form.submitted` | A form submission completes |
| `contact.created` | A new contact is added to an audience |
| `contact.updated` | An existing contact is modified |
| `campaign.deployed` | A campaign transitions to deployed |
| `campaign.paused` | A campaign is paused |
| `campaign.resumed` | A paused campaign resumes |
| `message.sent` | A message is queued for delivery |
| `message.delivered` | The provider confirms delivery |
| `message.bounced` | A hard bounce |
| `message.opened` | The recipient opened the message |
| `message.clicked` | The recipient clicked a link |
| `domain.verified` | A sender domain finishes DKIM verification |
| `usage.threshold` | A monthly usage threshold (50%, 80%, 100%) is crossed |

This catalog may change before launch. The final list will appear in `caramel.v1.meta.capabilities` once webhooks are live.

## Design hints

Build your handler now even though webhooks aren't live:

- **Make it idempotent.** You'll need this regardless of webhooks.
- **Return fast.** Do expensive processing asynchronously. The 5-second response deadline is a hard bound.
- **Log every signature failure.** A spike in failures indicates a missed rotation or an attempted attack.
- **Don't rely on delivery order.** Order may not be preserved across retries. Use timestamps from the event payload, not delivery sequence.
- **One subscription per business.** Each subscription is scoped to one business with its own signing secret.

## Next steps

- [Authentication](../get-started/authentication) — the OAuth token creates and manages webhook subscriptions.
- [Errors](errors) — webhook delivery failures use the standard error catalog.
- [Rate limits](rate-limits) — polling endpoints count against the same per-bearer bucket.
