PelekaPeleka Developers
Guides

Handling webhook events

Subscribe to workspace events and verify that a delivery actually came from Peleka.

Webhooks push events to your server as they happen, instead of you polling for changes. A contact unsubscribes, or a form gets submitted, and within seconds Peleka POSTs a JSON payload to the URL you registered.

Subscribing

curl -X POST https://api.peleka.io/api/v1/webhooks \
  -H "X-API-Key: pel_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/peleka",
    "events": ["contact.created", "contact.unsubscribed", "broadcast.completed"]
  }'
{
  "data": {
    "id": "550e8400-...",
    "url": "https://yourapp.com/webhooks/peleka",
    "events": ["contact.created", "contact.unsubscribed", "broadcast.completed"],
    "isActive": true,
    "secret": "whsec_7f3a9b2c1e8d4f6a0b5c9e1d3f7a2b8c"
  }
}

secret only appears in this create response and nowhere else by default. Copy it now. If you lose it, GET /webhooks/{id}/secret returns it again later; it isn't a reveal-once value, just one that isn't included in every list/update response.

What a delivery looks like

POST /webhooks/peleka HTTP/1.1
Content-Type: application/json
X-Peleka-Event: contact.unsubscribed
X-Peleka-Signature: t=1755000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-Peleka-Delivery: 4a1f9e2c-8b3d-4e6a-9c1f-2d5b8a7e3f04

{"event":"contact.unsubscribed","contactId":"8f14e45f-...","email":"[email protected]"}

X-Peleka-Delivery is a stable ID for this specific attempt, useful for deduplicating if you ever receive the same delivery twice (a slow response on your end can trigger a retry before your first response finishes).

Event payloads

The body is always {"event": "...", ...fields}, flattened rather than nested under a data key. Every ID in it is the same public UUID you'd get back from the REST API, not an internal database ID.

contact.created

Also covers contact.updated, contact.unsubscribed, contact.bounced, and contact.complained — same two fields, only event changes.

{"event": "contact.created", "contactId": "8f14e45f-...", "email": "[email protected]"}

contact.tag_added

Same shape for contact.tag_removed.

{"event": "contact.tag_added", "contactId": "8f14e45f-...", "email": "[email protected]", "tagId": "3c9e1f2a-...", "tagName": "VIP"}
{"event": "contact.link_clicked", "contactId": "8f14e45f-...", "email": "[email protected]", "url": "https://yoursite.com/pricing"}

broadcast.sent

Fires once sending starts, before any individual send has resolved.

{"event": "broadcast.sent", "broadcastId": "a1b2c3d4-...", "subject": "August Newsletter", "recipientCount": 4213}

broadcast.completed

Fires once every recipient's been attempted. status is sent if every send succeeded, partial if some failed but under 30% of recipients, or failed at or above that threshold.

{"event": "broadcast.completed", "broadcastId": "a1b2c3d4-...", "status": "sent", "deliveredCount": 4198, "permanentlyFailedCount": 15}

A broadcast can finish partial and still be worth treating as done: check permanentlyFailedCount against your own tolerance rather than branching only on status.

automation.enrolled

Same shape for automation.completed.

{"event": "automation.enrolled", "automationId": "7e2d9c1b-...", "contactId": "8f14e45f-..."}

form.submitted

{"event": "form.submitted", "formId": "5b3a8f7c-...", "contactId": "8f14e45f-...", "email": "[email protected]"}

Verifying the signature

Anyone can POST to your endpoint pretending to be Peleka. The signature is how you rule that out: HMAC-SHA256 over the timestamp and body, using the raw webhook secret as the key. Including the timestamp in the signed payload, not just the body, means a captured signature can't be replayed later. See the freshness check below.

const crypto = require('crypto');

function verifyPelekaSignature(rawBody, signatureHeader, secret) {
  const [tPart, v1Part] = signatureHeader.split(',');
  const timestamp = tPart.split('=')[1];
  const expectedSig = v1Part.split('=')[1];

  const signedPayload = `${timestamp}.${rawBody}`;
  const computedSig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  const sigMatches = crypto.timingSafeEqual(
    Buffer.from(computedSig, 'hex'),
    Buffer.from(expectedSig, 'hex'),
  );

  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  const withinTolerance = age >= 0 && age < 300; // 5 minutes

  return sigMatches && withinTolerance;
}

Two checks, not one. The HMAC comparison confirms the body wasn't tampered with and that whoever sent it knows your secret. The timestamp check stops someone from capturing a legitimate, correctly-signed request and replaying it later; without it, a signature that was valid once stays valid forever. Five minutes of tolerance is generous enough to absorb clock drift and network latency without leaving much of a replay window open.

Use crypto.timingSafeEqual for the comparison itself, not ===. A regular string comparison exits as soon as it finds a mismatched character, and the tiny timing difference that leaks is exactly what lets an attacker guess a signature one byte at a time.

Grab the raw body before any JSON parsing middleware touches it. Most frameworks reserialize the object when you read req.body, and reserialized JSON won't byte-for-byte match what was originally signed (key order, whitespace, and number formatting can all shift). In Express, that means reading the body with express.raw() on this specific route instead of the usual express.json().

Responding

Return a 2xx status within 10 seconds. Anything else (a timeout, a 4xx, a 5xx) gets retried up to 5 times with exponential backoff before that particular delivery is marked failed. If 10 deliveries in a row exhaust all their retries, Peleka disables the webhook automatically and stops sending to it until you re-enable it. Do the actual work (updating your database, calling another API) after you've responded, not before, if that work might take a while. Respond first, process second.

Debugging deliveries

GET /webhooks/{id}/deliveries lists recent attempts with status codes and response bodies, filterable by event type and delivery outcome — useful when a webhook silently stopped firing and you're trying to figure out whether the problem is on Peleka's side or yours. Each delivery can also be individually retried with POST /webhooks/{id}/deliveries/{deliveryId}/retry once you've fixed whatever was rejecting it.

Each retry gets its own row, not an update to the first one. A delivery that failed twice before succeeding shows up as three separate entries, attemptCount 1 through 3, so a spike in failed rows for one event doesn't necessarily mean three different events broke; it might be one that took three tries. responseStatus is null specifically when your server never responded at all (DNS failure, connection refused, or the 10-second timeout), which is worth distinguishing from an explicit 4xx/5xx since one points at reachability and the other at what your handler did with the request. deliveredAt is set only on the attempt that actually got a 2xx back; every other row has it null, including ones with a responseStatus present, since a 4xx or 5xx still counts as a completed request just not a successful one. The status=success and status=failed filters on the list endpoint use responseStatus for this: success is 200–299, failed is everything else, null included.

On this page