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

EventWhen
email.sentAccepted by the sending provider and on its way
email.deliveredConfirmed delivered to the recipient's mail server
email.openedThe tracking pixel loaded
email.clickedA tracked link was followed
email.bouncedPermanently or temporarily undeliverable
email.complainedThe recipient marked it as spam
email.rejectedThe provider declined to send it at all
contact.createdA contact was added, by import, form or API (one per new contact)
contact.unsubscribedA contact opted out
sequence.goal_reachedA contact hit a sequence's conversion goal
sequence.step_reachedA 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.

Something unclear or missing? Tell us.