Skip to content

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.

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

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.

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.

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.

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

Terminal window
# (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"
}

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

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.

AttemptDelay before retry
1 (initial)
230 seconds
35 minutes
430 minutes
52 hours
66 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.

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:

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));
}
# 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.

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 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.

EventWhen fired
form.submittedA form submission completes
contact.createdA new contact is added to an audience
contact.updatedAn existing contact is modified
campaign.deployedA campaign transitions to deployed
campaign.pausedA campaign is paused
campaign.resumedA paused campaign resumes
message.sentA message is queued for delivery
message.deliveredThe provider confirms delivery
message.bouncedA hard bounce
message.openedThe recipient opened the message
message.clickedThe recipient clicked a link
domain.verifiedA sender domain finishes DKIM verification
usage.thresholdA 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.

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.
  • 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.