# Workflow webhook

What SendOps POSTs to your endpoint when a [Drip Workflow](/api-reference/workflows) run reaches a `call webhook` step, how to verify that it came from us, and what to answer. This is the outbound counterpart of the [inbound webhook](/api-reference/inbound-webhook), which is mail arriving at one of your receiving domains.

## What a call is

A workflow author writes a step in the `.flow` source:

```sendflow
call webhook "mobile-push" as "cart-reminder" {
  failed: send "cart-reminder" via topic "marketing"
}
```

`"mobile-push"` is the **key** of a webhook endpoint configured under Connections → Webhooks with **Use for: Workflows**. It is a key, never a URL — the `.flow` source names a configured endpoint and the dashboard holds the address. `as "cart-reminder"` is an optional **label**, carried in the payload so one endpoint can serve several steps and tell them apart. The `failed:` arm is what the run does when the call does not succeed; it is optional, and without one the run simply carries on.

When a contact reaches that step, SendOps POSTs **one** signed envelope about that contact and **waits** for the answer. A 2xx and the run falls through to the next statement. Anything else, or nothing at all before the deadline, and the run takes the `failed:` arm.

Three properties follow, and each is load-bearing:

- **The run blocks on you.** It is parked, not spinning, but the contact's journey does not continue until the call resolves or the 30-minute deadline expires. Answer fast and do the work afterwards.
- **A run never fails because of a webhook.** Whatever your endpoint does, the worst outcome is the `failed:` arm. There is no state in which a drip run ends up dead because your service was down.
- **Exactly one POST per (run, node, iteration)** — plus retries, which carry the same id. See [Idempotency](#idempotency-deduplicate-on-id).

| | |
|---|---|
| Method | `POST` |
| `Content-Type` | `application/json` |
| `User-Agent` | `SendOps-Webhook/1.0` |
| `X-SendOps-Timestamp` | Unix seconds, as a decimal string |
| `X-SendOps-Signature` | `v1=<hex hmac-sha256>` — see [Verifying the signature](#verifying-the-signature) |
| `X-SendOps-Delivery-Id` | The envelope's `id`, repeated as a header |
| Response timeout | 10 seconds |


  There is no `/v1` route and no MCP tool that creates, edits or deletes a webhook endpoint, deliberately: the signing secret is shown exactly once, at creation, and a surface that mints one would have to return it to a caller that may be an agent. Create the endpoint under Connections → Webhooks, then reference its key from as many workflows as you like.


## The payload

```json
{
  "id": "6f9d1b0e-2c7a-5a31-9f44-0b6e8d3c1a27",
  "type": "workflow.step",
  "timestamp": "2026-09-19T10:14:02Z",
  "account_id": "3f1c9a52-7d84-4b60-9e21-5a0c2f7b8d13",
  "workflow": {
    "id": "8a2b4c6d-1e3f-4a5b-8c7d-9e0f1a2b3c4d",
    "key": "cart-abandonment",
    "name": "Cart Abandonment",
    "version": 3
  },
  "run_id": "b41e7f29-5c8d-4a13-9276-0e5f8c1d3a64",
  "node_path": "2",
  "iteration": 0,
  "label": "cart-reminder",
  "contact": {
    "id": "d7c3a91b-4e62-4f08-b5a7-2c9d1e0f3b48",
    "external_id": "cust_84213",
    "email": "dana@example.com",
    "attributes": {
      "plan": "pro",
      "lifetime_value": 482.5,
      "trial_extended": true,
      "renewal_date": "2026-11-01T00:00:00Z"
    }
  }
}
```

| Field | Type | Meaning |
|---|---|---|
| `id` | string (uuid) | The delivery id. Stable across every retry and every redelivery of the same call — **this is your deduplication key**. |
| `type` | string | Always `workflow.step`. Route on it: the same endpoint kind may carry more event types later. |
| `timestamp` | string | RFC 3339, UTC. When the call was **dispatched**, not when this attempt was made — a retry carries the original instant, because the envelope is byte-identical across retries. |
| `account_id` | string (uuid) | The SendOps organization the workflow belongs to. |
| `workflow.id` | string (uuid) | The workflow definition. |
| `workflow.key` | string | Its stable key, unique per organization. |
| `workflow.name` | string | Its display name. Changes when somebody renames it; do not key on it. |
| `workflow.version` | int | The definition version **this run is pinned to** — not necessarily the workflow's current version. A run executes the version it enrolled under for its whole life, so this is the version whose `node_path` makes sense. |
| `run_id` | string (uuid) | This contact's journey through this workflow. |
| `node_path` | string | The structural address of the `call webhook` statement inside `workflow.version`, e.g. `2` or `2/failed/0`. |
| `iteration` | int | The enclosing `repeat` block's iteration, `0` when there is none. |
| `label` | string or **null** | The step's `as "…"` name. **`null`, not absent**, when the step has none. |
| `contact.id` | string (uuid) | The SendOps contact. |
| `contact.external_id` | string or **null** | Your own stable id for this person, if you have ever supplied one. **`null`, not `""`**, when unset. |
| `contact.email` | string | The contact's address. |
| `contact.attributes` | object | **All** of the contact's attributes, as at dispatch. |
| `contact.attributes_omitted` | bool | Present and `true` only when the attribute bag was dropped for size. |

### `contact.attributes`

Every attribute the contact has, **typed as stored**: a number attribute deserializes as a JSON number, a boolean as a JSON boolean, a datetime as an RFC 3339 string, everything else as a string. A contact with no attributes carries `{}`.

There is no way to select a subset. That is deliberate — a step that sends "whatever this contact is" needs no configuration and cannot drift out of sync with the [attribute registry](/api-reference/contacts) — and it is the reason for the size cap.

### The size cap

If the marshalled envelope would exceed **256 KiB**, `contact.attributes` is emitted as `{}` and `contact.attributes_omitted` is set to `true`.

**No value is ever truncated.** A silently shortened attribute is worse than an absent one, because you cannot tell you are looking at a fragment. The bag goes whole or not at all, and `attributes_omitted` is how you know which. If you see it, read the attributes you need from the Public API using `contact.id`.

`attributes` stays present as an empty object rather than disappearing, so code that indexes into it does not have to handle two shapes.

## What to answer, and what happens next

| Your response | Outcome | Retried | Counts against endpoint health |
|---|---|---|---|
| `2xx` | ok — the run falls through | — | no (resets the counter) |
| `4xx` other than `408`/`429` | failed — a definitive business answer | no | no |
| `5xx`, `408`, `429`, a transport error, a timeout | failed after the retries are exhausted | 0 / 5 min / 15 min | yes |
| Endpoint missing, inactive, or the plan does not include webhooks | failed immediately, nothing is POSTed | no | no |
| Nothing by the 30-minute deadline | failed | — | — |

Reading that back in prose:

- **A 2xx is "yes".** The run continues at the statement after the call.
- **A 4xx other than 408 or 429 is "no", and it is believed.** Nothing is retried, the run takes the `failed:` arm, and your endpoint's health counter is untouched — because you answered, and answering "no" is not a fault. **`404` for "I have no device for this contact" is the intended use.**
- **408, 429 and every 5xx are "ask me again".** They are retried on a fixed 0 / 5 min / 15 min schedule. `Retry-After` is not read. Exhausting the schedule resolves the call as failed and counts against the endpoint's failing-health signal.
- **A transport error or a timeout is a retry too.** Ten seconds is the whole response budget.
- **A deleted, deactivated or unentitled endpoint fails the call immediately**, with no POST attempted. This is why deactivating an endpoint is a safe operation: flows that call it fork rather than stall.
- **Thirty minutes is the ceiling.** If nothing has resolved the call by then, the run resolves it itself as failed and takes the arm. A late result arriving afterwards changes nothing.


  It says "I will never accept this call" and is taken at its word: nothing retries and the run forks. Do not answer `4xx` for a transient problem — a full disk, a database that is down, a dependency that is slow. Answer `5xx` and the call comes back twice more.


**Answer quickly.** Ten seconds is the whole budget and a contact's journey is parked on it. Acknowledge with a 2xx and do the work afterwards.


  With the [send-approval gate](/api-reference/workflows#authoring-a-workflow) enabled, a call is held in the approvals queue beside sends. A reviewer who skips it sends nothing and the run continues **after the whole statement** — it does not take the `failed:` arm, because nobody said no.


### Your endpoint is never switched off

An endpoint used for org notifications is disabled after ten consecutive failures. A workflow endpoint is **not**, because disabling it would make every later call fail instantly and silently reroute live journeys down their `failed:` arms.

Instead, after **ten or more exhausted deliveries and zero successes in a trailing hour**, organization admins get a `workflow_webhook_failing` notification and the endpoint keeps being called. It recovers on its own the moment it starts answering. A definitive 4xx counts as neither a failure nor a success here: an endpoint answering "no device for this contact" four hundred times is the feature working, not an outage.

Delivery logs — request, response, status and timing per attempt — are kept for **30 days** and read in the dashboard.

## Verifying the signature

```http
X-SendOps-Timestamp: 1789208042
X-SendOps-Signature: v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-SendOps-Delivery-Id: 6f9d1b0e-2c7a-5a31-9f44-0b6e8d3c1a27
```

The scheme is **identical to the one org-notification webhooks use**, so an endpoint that already verifies those needs no new code. The signed string is `<timestamp> + "." + <raw request body>`, and the signature is HMAC-SHA256 under the endpoint's signing secret, hex-encoded, prefixed `v1=`.

Four rules, and skipping any one of them is a real vulnerability rather than a style point:

1. **Sign the raw body bytes**, before any JSON parsing. Re-serializing changes key order and whitespace and the signature will never match.
2. **Compare in constant time.** A `==` on the hex string leaks the correct signature one byte at a time to anyone who can time your endpoint.
3. **Reject a timestamp more than 5 minutes from now**, in either direction. Without this a captured request replays forever.
4. **Compare the whole header including the `v1=` prefix**, or strip it explicitly. Do not substring-match.

### Rotation keeps the previous secret for 24 hours

Rotating an endpoint's secret keeps the previous one valid for **24 hours**. During the window SendOps signs with the **new** secret only; the grace exists so you can roll the value through your own deployment without a gap. Accept either secret while you are mid-rollout, then drop the old one.

This is the opposite of the [inbound webhook](/api-reference/inbound-webhook#verifying-the-signature), whose rotation has no grace window at all, because that function reads a single secret out of a config object in your own AWS account.

### Node

```js
const crypto = require('crypto');

const TOLERANCE_SECONDS = 300;

// express: app.post('/hook', express.raw({ type: 'application/json' }), handler)
// `req.body` MUST be a Buffer of the raw bytes, not a parsed object.
function verify(req, secret) {
  const timestamp = req.get('X-SendOps-Timestamp');
  const signature = req.get('X-SendOps-Signature');
  if (!timestamp || !signature) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = 'v1=' + crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.')
    .update(req.body)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

### Go

```go

    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "math"
    "net/http"
    "strconv"
    "time"
)

const toleranceSeconds = 300

func verify(r *http.Request, body []byte, secret string) bool {
    ts := r.Header.Get("X-SendOps-Timestamp")
    sig := r.Header.Get("X-SendOps-Signature")

    n, err := strconv.ParseInt(ts, 10, 64)
    if err != nil {
        return false
    }
    if math.Abs(float64(time.Now().Unix()-n)) > toleranceSeconds {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(ts))
    mac.Write([]byte("."))
    mac.Write(body)
    expected := "v1=" + hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(sig))
}
```

### Python

```python




TOLERANCE_SECONDS = 300

def verify(headers, body: bytes, secret: str) -> bool:
    ts = headers.get("X-SendOps-Timestamp", "")
    sig = headers.get("X-SendOps-Signature", "")
    try:
        age = abs(int(time.time()) - int(ts))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False

    expected = "v1=" + hmac.new(
        secret.encode(), ts.encode() + b"." + body, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, sig)
```

## Idempotency: deduplicate on `id`

**`id` is derived, not minted.** It is a UUIDv5 over `<run_id>|<node_path>|<iteration>` under a fixed namespace, which means:

- **Every retry of the same call carries the same `id`.** Three attempts are one event, not three.
- **A redelivered internal tick carries the same `id`.** SendOps takes a database claim keyed on exactly those three values before it enqueues anything, so a step processed twice dispatches once — and even if it did not, the id would be the same.
- **A manual resend from the dashboard re-POSTs the identical body**, so the `id` is the same there too. It is re-signed with a fresh timestamp, and the body is the authority: match on the envelope's `id`, and treat `X-SendOps-Delivery-Id` as the convenience that lets you drop a repeat before parsing.

So: **store the ids you have processed and ignore repeats.** Delivery is at-least-once, and the duplicate you will actually see is the one every at-least-once system produces — you processed the request, your response did not arrive before the 10-second timeout, and it was retried.

A contact who enters the same workflow **again** is a new run, so a new `run_id`, so a new `id`. A step inside a `repeat` block produces a new `id` per iteration. Both are genuinely distinct calls and should be processed twice.


  Verify the signature against the raw bytes. Look up the envelope's `id` and return 2xx immediately if you have seen it. Otherwise record the id, answer 2xx, and do the work on your own time — or answer a definitive 4xx if the answer is a real no, such as a contact you have no device for.


## Pacing and the deadline

Deliveries to one endpoint are paced at **25 requests per second** by default. The limit is per **endpoint**, not per workflow and not per organization: the thing being protected is your server, so two flows pointed at the same URL share one allowance and two different URLs are limited independently.

It is a token bucket, so the allowance accrues continuously. Twenty-five requests may arrive together, but the twenty-sixth waits — you will not see a whole window's worth land in one millisecond and then silence.

A delivery that finds no token is put back with a one-to-three second pause and **consumes no attempt**. Pacing is not a failure: nothing is logged, nothing counts against your endpoint, and the delivery that eventually arrives is still attempt 1 of 3 carrying the id it always had.

The practical ceiling follows from the 30-minute step deadline:

```text
25 req/s × 1,800 s = 45,000 deliveries per endpoint per deadline window
```

A cohort larger than that arriving at one endpoint at once will see its tail resolve as failed on the deadline. Split the cohort across steps, or ask us to raise the rate for your endpoint.

Deliveries run on their own internal queue, so a large cohort's POSTs cannot delay — or be delayed by — the rest of your workflows' work.

## Plan availability

Workflow webhooks are entitled on the same plans as notification webhooks: **Team and above**. On a plan without them, a `call webhook` step fails immediately with nothing POSTed and the run takes its `failed:` arm, exactly as it would for a deleted endpoint.

## Versioning

The envelope has **no `schema_version`**. `type` is the discriminator, and the rules are the ones the [inbound contract](/api-reference/inbound-webhook#versioning) states:

- **New optional fields are added without notice.** An endpoint written before a field existed ignores it, which is what a JSON deserializer does by default — **so do not configure yours to reject unknown fields.** That is the one client-side choice that turns an additive change into an outage.
- **No field is ever renamed or repurposed.**
- **A genuinely incompatible shape would arrive as a new `type`**, not as a changed `workflow.step`.

## Trying it before a run does

The endpoint's **Test** button under Connections → Webhooks POSTs a synthetic, signed `workflow.step` envelope with obviously fake ids to your URL and reports what came back — status, timing, and a plain-language description of a transport failure. It exercises the exact signing path a real call uses.

The test send is dashboard-only and deliberately not on the Public API: it makes SendOps POST to an address the caller chooses, which is an outbound-request primitive, and a person on the settings page having just pasted a URL is part of what keeps it from being used as one.

To rehearse the **workflow** rather than the endpoint, [`POST /v1/workflows/{id}/dry-run`](/api-reference/workflows#dry-run-simulate-before-you-activate) traces a contact through the definition and never calls anything. Pass `webhook_outcome: "failed"` to trace the `failed:` arm.

## Related

- [Workflows](/api-reference/workflows) — the API that authors, activates and reports on the flow this step lives in, including the `webhook_*` timeline events and the funnel's `webhooks[]` counts
- [SendFlow reference](https://www.sendlang.com/docs/sendflow) — the `call webhook` statement's grammar
- [Inbound webhook](/api-reference/inbound-webhook) — the other direction: mail arriving at one of your receiving domains