Webhooks
Get sends, opens, clicks and conversions pushed to your own system.
Events are what you send us. Webhooks are the other direction: Pombo posts to a URL you control as things happen, so you can join our half of the funnel to yours.
The usual reason to want this is attribution. Pombo knows the send, the open and the click. You know the signup and the purchase. Nobody can join those until one side is pushed to the other.
Setting one up
Settings → Webhooks → Add endpoint. Give it an https URL, pick the events you
want, and copy the signing secret.
Plain http is refused. The payloads carry your subscribers' email addresses
and those are not going over the wire in the clear.
Verifying you own the endpoint
Nothing is delivered until you prove you control the URL. Press Verify endpoint and we POST a challenge:
{
"type": "endpoint.verification",
"data": { "challenge": "whcl_r0Yk...", "endpoint_id": "..." }
}
Answer 2xx with that challenge as the body, or as {"challenge": "..."},
within 10 seconds. That is it. The challenge is single-use and a new one is
issued every attempt.
post "/webhooks/pombo" do
event = JSON.parse(request.body.read)["events"].first
halt 200, event.dig("data", "challenge") if event["type"] == "endpoint.verification"
# ... handle real events
end
The challenge arrives in the same envelope as everything else, so the parser you write here is the one you keep.
This is WebSub's handshake, and the reason is
the one WebSub gives: it exists "to prevent an attacker from creating unwanted
subscriptions on behalf of a subscriber". Without it, anyone with a Pombo
account could point an endpoint at your servers, subscribe to email.opened,
and use our senders to flood you. A URL nobody proved they own is an amplifier.
The verification request is signed like any other delivery, so you can check who is asking before echoing anything back.
Changing an endpoint's URL sends it back to unverified. Control of the old host says nothing about the new one.
If you are still building the receiver, point it at a request-capture service over https and verify against that first.
Events
| Event | When |
|---|---|
email.sent | Accepted by the sending provider and on its way |
email.delivered | Confirmed delivered to the recipient's mail server |
email.opened | The tracking pixel loaded |
email.clicked | A tracked link was followed |
email.bounced | Permanently or temporarily undeliverable |
email.complained | The recipient marked it as spam |
email.rejected | The provider declined to send it at all |
contact.created | A contact was added, by import, form or API (one per new contact) |
contact.unsubscribed | A contact opted out |
sequence.goal_reached | A contact hit a sequence's conversion goal |
sequence.step_reached | A contact reached a webhook step you placed in a flow |
sequence.step_reached is not subscribable: only a step can raise it, and the
step decides where it goes. See below.
email.sent and email.opened are high volume. A busy account produces tens
of thousands a day, and subscribing to them without somewhere to put them is
the usual way this gets unpleasant. Start with email.delivered,
email.clicked, email.bounced and sequence.goal_reached.
contact.created fires for every new contact, including each row of a CSV
import, so importing 50,000 contacts is 50,000 events. Contacts that were
already in your audience do not fire it again. If you subscribe to it and
import large lists, turn batching up first.
What arrives
Every request is an envelope holding an array of events, always, even when there is only one:
{
"id": "batch_8f21...",
"api_version": "2026-09-11",
"created_at": "2026-09-11T12:04:01Z",
"organization": { "id": "...", "slug": "acme", "name": "Acme" },
"count": 1,
"events": [
{
"id": "evt_9f2c1b8e-...",
"type": "email.clicked",
"occurred_at": "2026-09-11T12:03:58Z",
"data": {
"email": {
"id": "...",
"message_id": "0102018f...",
"subject": "Your order shipped",
"to": "[email protected]",
"status": "delivered"
},
"contact": {
"id": "...",
"email": "[email protected]",
"first_name": "Sam",
"status": "subscribed",
"custom_fields": { "plan": "pro", "your_user_id": "u_8812" }
},
"attribution": {
"campaign_id": "...",
"sequence_id": "...",
"template_id": "..."
}
}
}
]
}
So the handler is a loop, and it is the same loop whatever the batch size:
JSON.parse(body)["events"].each { |event| handle(event) }
The shape never changes. Turning batching on later is a performance decision,
not a breaking change to your code, which is why we send an array even when
count is 1. SendGrid and SparkPost do the same thing for the same reason.
Only data varies between event types, so you can route on type before
parsing anything specific, and dedupe on the event's id.
contact.custom_fields goes out whole, which is where you will find whatever
id you store against a contact. Putting your own user id in a custom field is
the simplest way to make these joinable.
Verifying a delivery
We follow Standard Webhooks, so a library for your language probably already does this. Every request carries:
webhook-id: evt_9f2c1b8e-...
webhook-timestamp: 1757592000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Pombo-Event: email.clicked
Compute base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}"))
and compare it to the v1, value in constant time. The key is the base64 after
whsec_, decoded, not the prefixed string.
Use the raw body, before any JSON parsing or re-encoding.
Reject anything where the timestamp is more than five minutes from now. Both the id and the timestamp are inside the signed material: the timestamp is what stops a captured delivery being replayed forever, and the id is what stops a signature being lifted onto a different event that happens to carry the same body in the same second.
def verify(id, timestamp, signature_header, body, secret)
return false if (Time.now.to_i - timestamp.to_i).abs > 300
key = Base64.decode64(secret.delete_prefix("whsec_"))
expected = Base64.strict_encode64(
OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")
)
signature_header.split(" ").any? do |candidate|
scheme, value = candidate.split(",", 2)
scheme == "v1" && ActiveSupport::SecurityUtils.secure_compare(expected, value.to_s)
end
end
The header may carry several space-delimited signatures during a secret rotation. Any one matching is a pass.
You can rotate the secret from Settings → Webhooks. The old one stops working immediately, so change it on your side first if you cannot tolerate a gap.
Calling a webhook from inside a sequence
Everything above is a subscription: you pick event types and receive them for the whole account. Sometimes what you want is narrower, "when a contact reaches this point in this flow, tell my system". That is a webhook step.
In the sequence builder, add a Perform action step, set it to Call a webhook, and choose one of your verified endpoints. The optional note is sent in the payload so your handler can tell one step from another without hardcoding our ids.
The event type is sequence.step_reached, and it is delivered to the endpoint
the step names whether or not that endpoint subscribed to anything. Choosing it
in the flow is the subscription.
{
"type": "sequence.step_reached",
"data": {
"contact": { "id": "...", "email": "[email protected]" },
"sequence": { "id": "...", "name": "Trial onboarding" },
"step": { "id": "...", "name": "Tell the CRM", "position": 4 },
"enrollment": { "id": "...", "entered_at": "2026-09-01T09:00:00Z" },
"note": "trial-ending branch"
}
}
The contact moves on immediately. We do not wait for your endpoint to answer, because a blocking step means one slow endpoint stalls every contact queued behind it. Failures retry on the normal schedule and show up in Settings → Webhooks like any other delivery.
Previewing a sequence never posts anything. The preview timeline says where it would have gone instead.
Batching
Because every request is already an array, batching is a dial rather than a
different format. Raise Batching on the endpoint and count goes up:
{ "id": "batch_8f21...", "count": 37, "events": [ ... ] }
Your handler does not change. That is the whole reason the array is there even
at count 1.
An endpoint subscribed to email.opened on a busy account receives tens of
thousands of requests a day, and most of that cost is yours: your handler pays
TLS and framing per event. Batching turns that into hundreds.
A full batch is sent as soon as it fills. A partial one waits up to a minute for company and then goes anyway, so a quiet endpoint is never left holding events.
The signature covers the whole body and webhook-id is the envelope id.
Verify it exactly as you would any other request, then dedupe the individual
events on their own id values inside.
A batch succeeds or fails as one. If you answer non-2xx we retry the same events under the same envelope id rather than breaking them up, and anything you already received in an earlier successful batch is left out.
Delivery
Answer 2xx and quickly. Anything else is a failure, and so is taking longer
than 15 seconds over the whole response. We keep the first 8 KB of what you
send back, for the delivery log, and ignore the rest. If you need to do real
work, put the payload on your own queue and answer straight away.
A failed request is retried up to seven more times, after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 8 hours and 10 hours: eight attempts over a little more than a day.
Each interval carries a little random jitter. If your endpoint goes down, everything that failed during the outage would otherwise retry at exactly the same moments and stampede you the instant you came back.
Delivery is at-least-once and order is not guaranteed. A retry, a
redelivery and a repeated notification from the provider all carry the same
event id, so dedupe on that rather than on the envelope id. Do not assume email.delivered arrives before
email.opened; use occurred_at if sequence matters.
A request only counts against your endpoint once it has used up all eight attempts, and it counts once however many events it carried. Twenty of those in a row switches the endpoint off, and Settings → Webhooks shows why and lets you turn it back on. Any successful delivery resets the count, so a flaky afternoon will not disable you: it takes an endpoint that has been failing for more than a day.
Nothing is thrown away while an endpoint is off or waiting to be verified again. Whatever was still waiting goes out once you turn it back on or verify it.
What we will not post to
The URL is re-checked every time we deliver, not only when you save it. We refuse anything that resolves to a private address: loopback, RFC 1918, link-local, carrier-grade NAT. This is not about trusting you, it is that an endpoint pointed at cloud metadata is how a webhook feature becomes a way to read someone else's credentials.
A hostname that does not resolve yet is fine to save. It just will not deliver until it does.