# Webhooks via Flows

> Send signed Cevoid events to your HTTPS endpoint from a flow.

Breadcrumb: Flows › Webhooks via Flows · Updated: 2026-10-17
Docs index: https://docs.cevoid.com/llms.txt

Webhooks let a flow notify your own service when something happens in Cevoid, such as an order being fulfilled or a review being submitted. Your service receives a signed `POST` with the order, profile, or review the event concerns, so you can act on it without polling for changes.

Every request is signed, so you can verify it came from Cevoid. Requests are fixed `POST` calls with a JSON body. Custom methods, custom authorization headers, and inbound webhooks are not supported.

> [!NOTE]
> This setup needs a service of your own that receives webhook requests. If you don't build it yourself, share this article with your developer.

## Prerequisites

- You can access _Settings_ and _Flows_ in your Cevoid workspace
- You have a public HTTPS receiver that accepts `POST` requests, preserves the exact raw request body for signature verification, does not redirect, and returns a `2xx` response after accepting an event

## Set up a webhook

You create a webhook destination from the flow that sends to it. Cevoid shows the destination's signing secret once, when you create it.

1. Navigate to [Settings → Flows](https://app.cevoid.com/settings/flows) and click **Create flow**
2. Select **Forward event**, then **Webhook**
3. Select the trigger, such as **Order fulfilled** or **Product review submitted**. Every trigger can send its event to a webhook: see [Triggers](https://docs.cevoid.com/flows.md#triggers).
4. Select **Create destination**. To use a destination you created before, select it instead and go to step 8: the flow opens in the editor.
5. Enter a name, your receiver's HTTPS URL, and optionally a description
6. Click **Create destination**. The flow opens in the editor, and the signing secret shows.
7. Copy the signing secret, store it securely, and click **I saved the secret**
8. Click **Publish flow**. Matching events start being delivered.

To send to a webhook from a flow you already have, see [Create a webhook destination](https://docs.cevoid.com/flows.md#create-a-webhook-destination).

## Request contract

Cevoid sends an HTTPS `POST` with `content-type: application/json` and these [Standard Webhooks](https://www.standardwebhooks.com/) headers:

- `webhook-id`: the event's ID, the same as `id` in the body. Automatic retries and **Retry** in Outbox keep it.
- `webhook-timestamp`: Unix seconds for this attempt
- `webhook-signature`: one or more `v1,<base64>` signatures

Every event uses the same envelope. `type` tells you what happened, `data` holds the resources it concerns, and `origin.flow` names the flow that sent it, which is useful when several flows point at one destination. `created_at` is when the delivery was scheduled to send, after any wait in the flow. To see the `data` your flow sends, click **Preview payload** on its _Action_ step.

```json
{
  "version": 1,
  "id": "d8177c1d-39da-4c4d-93cd-5964043735e4",
  "type": "order.fulfilled",
  "created_at": "2026-08-03T12:00:00.000Z",
  "data": {
    "order": { "id": "ord_7k2m4n8p", "order_number": "#1043", "currency": "SEK" },
    "profile": { "id": "prf_c4n8x2q1", "name": "Astrid Lindqvist" }
  },
  "origin": {
    "flow": { "id": "wfl_3p9v6d2s", "name": "Post-purchase review request" }
  }
}
```

A test run of a flow's live version is signed and delivered the same way.

## Verify the exact raw body

Verify the signature before parsing JSON. Build the signed content as:

```text
webhook-id + "." + webhook-timestamp + "." + exact_raw_request_body
```

Decode the base64 value after `whsec_`, calculate HMAC-SHA256, encode the digest as base64, and compare it in constant time with any `v1` signature in `webhook-signature`. Reject stale timestamps according to your replay-risk policy.

### Node.js

```javascript
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verifyCevoidWebhook({ rawBody, headers, secret }) {
  const id = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  const signatureHeader = headers['webhook-signature']
  if (typeof signatureHeader !== 'string') return false

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const expected = createHmac('sha256', key)
    .update(`${id}.${timestamp}.${rawBody}`, 'utf8')
    .digest()

  return signatureHeader.split(' ').some((entry) => {
    const [version, encoded] = entry.split(',', 2)
    if (version !== 'v1' || !encoded) return false
    const received = Buffer.from(encoded, 'base64')
    return received.length === expected.length && timingSafeEqual(received, expected)
  })
}
```

Pass `rawBody` directly from your framework's raw-body middleware. Do not use `JSON.stringify(req.body)`.

### Python

```python
import base64
import hashlib
import hmac

def verify_cevoid_webhook(raw_body: bytes, headers: dict[str, str], secret: str) -> bool:
    webhook_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    key = base64.b64decode(secret.removeprefix("whsec_"), validate=True)
    signed = webhook_id.encode() + b"." + timestamp.encode() + b"." + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    signature_header = headers.get("webhook-signature")
    if not signature_header:
        return False

    for entry in signature_header.split(" "):
        version, separator, signature = entry.partition(",")
        if version == "v1" and separator and hmac.compare_digest(signature, expected):
            return True
    return False
```

### HMAC test vector

Use this example to check your verification code. The raw body is the example event above as Cevoid sends it: compact JSON, with its keys in alphabetical order.

```text
secret: whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
webhook-id: d8177c1d-39da-4c4d-93cd-5964043735e4
webhook-timestamp: 1785758400
raw body: {"created_at":"2026-08-03T12:00:00.000Z","data":{"order":{"currency":"SEK","id":"ord_7k2m4n8p","order_number":"#1043"},"profile":{"id":"prf_c4n8x2q1","name":"Astrid Lindqvist"}},"id":"d8177c1d-39da-4c4d-93cd-5964043735e4","origin":{"flow":{"id":"wfl_3p9v6d2s","name":"Post-purchase review request"}},"type":"order.fulfilled","version":1}
webhook-signature: v1,cAZHMKsBy3Bn+Ck4hAx9oiCM49JIe1BJ1mgwZ8aYP2Q=
```

## Respond and deduplicate

- Return any `2xx` response once you have durably accepted the event. Cevoid then stops retrying it.
- Store `webhook-id` and skip an ID you have already processed. This way, a retry after a lost response cannot create a duplicate on your side.
- Sequence your own state from `created_at` or from the resource in `data`, not from arrival order. Events for the same order or profile can arrive in any sequence.
- Do not redirect. Cevoid sends every request to the URL you configured.

### Retries

Cevoid retries `408`, `425`, `429`, and `5xx` responses up to 6 more times, over about 21 hours, waiting longer each time. A `Retry-After` header is honored when it asks for longer. Cevoid waits 10 seconds for a response, and retries a timeout or a network error the same way. See [Automatic retries](https://docs.cevoid.com/flows/outbox.md#automatic-retries).

Other `3xx` and `4xx` responses stop the delivery. After a `401` or `403`, the delivery shows _Provider authorization failed_: fix your receiver, then retry it in [Outbox](https://docs.cevoid.com/flows/outbox.md#skip-send-now-retry-or-replay-a-delivery). Any other `3xx` or `4xx` shows _Invalid delivery payload_, which can't be retried: fix your receiver, then replay the delivery as a new one.

### URL requirements

Destination URLs must use HTTPS and resolve to a public address. They cannot contain credentials, fragments, or query strings. They need a hostname, not an IP address or `localhost`, and can't use ports 22, 23, 25, 110, or 143. Cevoid checks the URL when you create the destination, and again before each request.

## Test, retry, and replay

- **Trigger test run** runs the flow once: see [Test a flow](https://docs.cevoid.com/flows/flow-runs.md#test-a-flow). With the live version, it posts a real signed request to your endpoint. With the current draft, Cevoid only validates the payload and sends no request.
- **Retry** in Outbox sends the same event again, with the same `webhook-id` and the same body, up to 3 times. Use it after fixing your receiver.
- **Replay as new delivery** in Outbox sends the same event data as a new event, with a new `webhook-id`, to the same destination. Because the ID is new, your deduplication doesn't filter it.
- **Retry from start** and **Retry with live flow** on a [flow run](https://docs.cevoid.com/flows/flow-runs.md#retry-a-run) send a new delivery, with a new `webhook-id`. **Retry from failed** keeps the same `webhook-id`.

Every delivery shows in [Outbox](https://docs.cevoid.com/flows/outbox.md), with each attempt and its response status. Filter by _Webhook destination_ to see one destination's deliveries. To retry or replay a delivery, see [Skip, send now, retry, or replay a delivery](https://docs.cevoid.com/flows/outbox.md#skip-send-now-retry-or-replay-a-delivery).

## Troubleshooting

- **No request arrives:** Confirm that the flow is live, then check the delivery in [Outbox](https://docs.cevoid.com/flows/outbox.md#open-a-deliverys-details)
- **Signature mismatch:** Verify against the exact raw bytes, not parsed JSON, and remove `whsec_` before base64 decoding
- **Repeated events:** Deduplicate on `webhook-id`
- **Redirect or network error:** Create a destination with the final public HTTPS URL, and select it in _Webhook destination_ in the flow's _Action_ step. Redirects and private addresses are blocked.
- **Delivery stopped retrying:** Fix the reason shown on the delivery in Outbox, then retry it, or replay it when **Retry** isn't offered
- **Secret was lost:** Create a new destination, which comes with a new signing secret, and select it in _Webhook destination_ in the flow's _Action_ step

## Related articles

- [Flows](https://docs.cevoid.com/flows.md)
- [Outbox](https://docs.cevoid.com/flows/outbox.md)
- [Review requests](https://docs.cevoid.com/reviews/collect/review-requests.md)
- [Profile data and event sharing](https://docs.cevoid.com/profiles/profile-data-event-sharing.md)

---

Canonical: https://docs.cevoid.com/flows/webhooks
