> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cevoid.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks via Flows

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

Webhooks let a Flow notify your own service when something happens in Cevoid — an order is fulfilled, a review is 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.

## 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

1. Go to *Settings* → *Integrations* → *Cevoid API*.
2. Create a webhook destination. Result: Cevoid shows the signing secret once — copy it now and store it securely.
3. Go to *Settings* → *Flows* and create a Flow.
4. ***Choose*** `order.fulfilled`, `order.delivered`, `product-review.submitted`, or `company-review.submitted` as the trigger.
5. Add a webhook action and choose your destination.
6. Publish the Flow. Result: matching events start being delivered.

You can also create the Flow directly from a destination.

## Request contract

Cevoid sends an HTTPS `POST` with `content-type: application/json` and these [Standard Webhooks <Icon icon="square-arrow-out-up-right" />](https://www.standardwebhooks.com/) headers:

* `webhook-id`: stable public event ID; retries keep the same value
* `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 — useful when several Flows point at one destination.

```json theme={"system"}
{
  "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" }
  }
}
```

Test sends are signed and delivered the same way, with `type: "cevoid.test"`.

## Verify the exact raw body

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

```text theme={"system"}
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. During secret rotation, accept a match from either current secret while both signatures are present.

### Node.js

```js theme={"system"}
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 theme={"system"}
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

This fixture is derived from the automated signing contract:

```text theme={"system"}
secret: whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
webhook-id: d8177c1d-39da-4c4d-93cd-5964043735e4
webhook-timestamp: 1785758400
raw body: {"created_at":"2026-08-03T12:00:00.000Z","data":{"message":"Test delivery from Cevoid"},"id":"d8177c1d-39da-4c4d-93cd-5964043735e4","type":"cevoid.test","version":1}
webhook-signature: v1,hcbQFW+SLYnH9b62UxCSDnPyZW1rVaGPcA52jb9LBI0=
```

## Respond and deduplicate

* Return any `2xx` response once you have durably accepted the event. Result: Cevoid stops retrying it.
* Store `webhook-id` and skip an ID you have already processed. Result: 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 with a widening delay, over roughly a day. A `Retry-After` header is honoured when it asks for longer.

Other `3xx` and `4xx` responses stop the delivery. Fix your receiver, then retry it from the delivery log.

### URL requirements

Destination URLs must use HTTPS and resolve to a public address. They cannot contain credentials, fragments, or query strings.

## Test, retry, and replay

* **Send test** sends a real signed `cevoid.test` request so you can check your receiver end to end.
* **Retry** sends the same event again, with the same `webhook-id` and the same body. Use it after fixing your receiver.
* **Replay** sends the same event data as a **new** event, with a new `webhook-id`, to your destination's current URL. Because the ID is new, your deduplication will not filter it.

Each destination has a delivery log showing every attempt, the response status, and a preview of the response body.

## Troubleshooting

* **No request arrives:** confirm the destination and the Flow are active, then check the delivery log.
* **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:** point the destination at the final public HTTPS URL. Redirects and private addresses are blocked.
* **Delivery stopped retrying:** fix the reason shown in the delivery log, then retry it.
* **Secret was lost:** rotate it. Accept both signatures during the overlap, deploy the new secret, then finish rotation.
