Webhooks
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
Section titled “What to do today”Until outbound webhooks ship, observe state changes by polling the existing tools.
Form submissions
Section titled “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 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
Section titled “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
Section titled “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
Section titled “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
Section titled “Subscribing to events”# (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.
{ "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
Section titled “Event delivery shape”Every event is delivered as a POST to your registered URL:
POST /hooks/caramel HTTP/1.1Content-Type: application/jsonx-caramel-event: form.submittedx-caramel-delivery: dlv_01h7m9k0aabcdefghj1234567x-caramel-timestamp: 1717684350x-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
Section titled “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
Section titled “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:
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));}# Pythonimport 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
Section titled “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:
// Use your database, not an in-memory Set, in productionconst 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
Section titled “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
Section titled “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
Section titled “Next steps”- Authentication — the OAuth token creates and manages webhook subscriptions.
- Errors — webhook delivery failures use the standard error catalog.
- Rate limits — polling endpoints count against the same per-bearer bucket.