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

# Webhook events

> Every webhook event, its data, the delivery envelope and headers, signature verification and retries.

wede notifies your endpoint with an HTTPS `POST` when something happens in your tenant. Register endpoints with [`POST /v1/webhooks`](/api-reference/webhooks/create) and choose the events you want.

## Envelope

Every delivery has the same envelope. The `data` object depends on the event.

```json theme={null}
{
  "id": "5d0c8e2a-4b6f-4a1d-9c3e-7f2b1a0d9e8c",
  "event": "event.delivered",
  "created_at": "2026-09-26T09:12:07.044Z",
  "tenant_id": "9eb65ffa-3c1d-4d7e-8f02-1a2b3c4d5e6f",
  "environment": "production",
  "data": {
    "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40",
    "status": "completed",
    "channel": "sms",
    "attempts": 1
  }
}
```

| Field | Type | Description |
| - | - | - |
| `id` | string | Delivery ID (UUID). The same across retries of this delivery. Use it to ignore duplicates |
| `event` | string | Event name, for example `event.delivered` |
| `created_at` | string | When wede created the delivery (ISO 8601) |
| `tenant_id` | string | Your tenant ID |
| `environment` | string | `production` or `sandbox`: the environment of the event that caused the delivery |
| `data` | object | Event data, described below |

## Headers

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-Wede-Signature` | `t=<unix seconds>,v1=<hex>`. See [Verify the signature](#verify-the-signature) |
| `X-Wede-Timestamp` | The same unix timestamp as `t` |
| `X-Wede-Event` | The event name |
| `X-Wede-Delivery` | The delivery ID, same as the envelope `id` |
| `X-Wede-Attempt` | Attempt number: `1`, `2` or `3` |
| `User-Agent` | `Wede-Webhook/1.0` |

## Event catalogue

### Event delivery

> **event.created**
>
> An event was accepted, online through `POST /v1/events` or offline through `POST /v1/sync/batch`. The data includes your payload.
>
> Online:
>
> ```json theme={null}
> {
>   "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40",
>   "type": "cardiac_arrest",
>   "category": "pre",
>   "requires_teams": true,
>   "vertical": "healthcare",
>   "priority": "critical",
>   "channel": "rest_full",
>   "payload": { "incident_type": "CAR", "patient_ref": "PT-001" },
>   "labels": {
>     "incident_type": { "label": "Incident type", "value_label": "Cardiac arrest" },
>     "patient_ref": { "label": "Patient reference" }
>   },
>   "metadata": { "zone_id": "zone_lisbon_central" },
>   "dispatch": { "status": "queued" }
> }
> ```
>
> `category` is the code of the category of your catalog the type belongs to. `requires_teams` says whether the type sends a field team, and `dispatch.status` what the optional teams module will do: `queued`, `manual`, `not_dispatchable` (with `reason: type_does_not_require_teams`), `off` or `sandbox`. `metadata` is present only when the event was sent with metadata. `labels` gives, for each payload key that is a field of the [parser](/api-reference/parsers/list.md) the event was written with, the field label and, for enumerated fields, the label of the chosen option; the codes in `payload` never change, so your systems can keep matching on them. `labels` is absent when the event has no parser.
>
> From sync:
>
> ```json theme={null}
> {
>   "event_id": "7c2a4e1f-0b3d-4a5c-9e8f-1d2c3b4a5e6f",
>   "type": "delivered",
>   "category": "log",
>   "requires_teams": false,
>   "vertical": "logistics",
>   "priority": "normal",
>   "payload": { "parcel": "PX-2231" },
>   "offline_generated_at": "2026-09-26T08:41:17.000Z",
>   "origin": "sync"
> }
> ```

> **event.delivered**
>
> The event was delivered over the internet path (`rest_full` or `rest_compressed`) or through a channel of your cascade. The event is now `completed`. Its stored payload is cleared at this point for a type that does not send a team; for a type that sends a team it is kept until the event is closed or its missions end (see [Get event](/api-reference/events/get.md#status-and-channel)).
>
> ```json theme={null}
> { "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40", "status": "completed", "channel": "sms", "attempts": 1 }
> ```
>
> `channel` is the delivery channel: `rest_full`, `rest_compressed`, `sms`, `voice`, `satellite`, `lora` or `edge`. `attempts` counts every attempt across the cascade.

> **event.failed**
>
> No channel delivered the event and it could not be queued.
>
> ```json theme={null}
> { "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40", "status": "failed", "channel": "voice", "attempts": 5, "error": "..." }
> ```
>
> `channel` is the last channel tried. `error` is the last error, or `null`.

> **event.status\_changed**
>
> The event was queued for delivery when connectivity returns (store-and-forward, or every channel of the cascade was exhausted). It stays `pending` until it is reconciled.
>
> ```json theme={null}
> { "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40", "status": "pending", "channel": "queued_offline", "attempts": 1 }
> ```
>
> Missions never change the status of an event, so a mission outcome does not send this webhook. Follow missions with `mission.status_updated`.

### Offline sync and devices

> **sync.completed**
>
> A sync batch was processed. Sent once per batch.
>
> ```json theme={null}
> { "summary": { "total": 3, "accepted": 1, "duplicates": 1, "rejected": 1 } }
> ```

> **device.synced**
>
> A registered device synced its queue.
>
> ```json theme={null}
> { "device_id": "courier-17-phone", "accepted": 4, "duplicates": 0 }
> ```
>
> `accepted` and `duplicates` are counts.

### Connectivity

> **connectivity.degraded**
>
> A zone left `online`.
>
> ```json theme={null}
> {
>   "zone_id": "zone_lisbon_central",
>   "previous_state": "online",
>   "state": "sms_only",
>   "detected_at": "2026-09-26T09:00:00.000Z"
> }
> ```
>
> Changes between two states that are not `online` (for example `degraded` to `offline`) do not send a webhook. Read the current state with [`GET /v1/connectivity/status`](/api-reference/connectivity/status).

> **connectivity.restored**
>
> A zone returned to `online`.
>
> ```json theme={null}
> {
>   "zone_id": "zone_lisbon_central",
>   "previous_state": "sms_only",
>   "state": "online",
>   "detected_at": "2026-09-26T09:25:00.000Z"
> }
> ```

`zone_id` is the zone code. With a sandbox key, these events follow the simulated state of the zone and carry `"environment": "sandbox"`.

### Teams

> **team.created**
>
> ```json theme={null}
> { "team_id": "3b5d7f9a-1c2e-4a6b-8d0f-2e4a6c8e0b1d", "name": "Ambulance 12", "vertical": "healthcare" }
> ```

> **team.updated**
>
> `fields` lists the names of the fields that changed.
>
> ```json theme={null}
> { "team_id": "3b5d7f9a-1c2e-4a6b-8d0f-2e4a6c8e0b1d", "fields": ["status"] }
> ```

> **team.deleted**
>
> ```json theme={null}
> { "team_id": "3b5d7f9a-1c2e-4a6b-8d0f-2e4a6c8e0b1d" }
> ```

> **team.dispatched**
>
> A team was dispatched to an event. The data depends on how.
>
> By a user:
>
> ```json theme={null}
> { "team_id": "3b5d7f9a-1c2e-4a6b-8d0f-2e4a6c8e0b1d", "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40", "notes": "Closest unit" }
> ```
>
> When the operator dispatched a team that does not cover the event type, or lacks equipment the type calls for, the data also carries `warnings`, for example `[{ "code": "no_coverage" }, { "code": "missing_equipment", "detail": ["defibrillator"] }]`. The dispatch still happened.
>
> Automatically:
>
> ```json theme={null}
> { "team_id": "3b5d7f9a-1c2e-4a6b-8d0f-2e4a6c8e0b1d", "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40", "auto": true }
> ```
>
> As a substitution, when the previous team did not acknowledge within your acknowledgement timeout. The previous mission is set to `FAILED` (you also receive `mission.status_updated` for it) and a new mission is created for the replacement team:
>
> ```json theme={null}
> {
>   "team_id": "8c0e2a4b-6d8f-4a1c-9e3b-5d7f9a1c3e5b",
>   "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40",
>   "mission_id": "f5b2d4c6-8e0a-4c3b-9d7f-1a3c5e7b9d0f",
>   "substitution": true,
>   "replaced_team_id": "3b5d7f9a-1c2e-4a6b-8d0f-2e4a6c8e0b1d",
>   "replaced_mission_id": "e4a1c3b5-7d9f-4b2a-8c6e-0f1d3a5b7c9e"
> }
> ```

> **team.equipment.added**
>
> ```json theme={null}
> { "team_id": "3b5d7f9a-1c2e-4a6b-8d0f-2e4a6c8e0b1d", "code": "defibrillator", "name": "Defibrillator" }
> ```

> **team.equipment.updated**
>
> `status` and `quantity` are `null` when they were not changed.
>
> ```json theme={null}
> { "team_id": "3b5d7f9a-1c2e-4a6b-8d0f-2e4a6c8e0b1d", "equipment_id": "a2c4e6f8-0b1d-4f3a-9c5e-7b9d1f3a5c7e", "status": "maintenance", "quantity": null }
> ```

> **team.equipment.removed**
>
> ```json theme={null}
> { "team_id": "3b5d7f9a-1c2e-4a6b-8d0f-2e4a6c8e0b1d", "equipment_id": "a2c4e6f8-0b1d-4f3a-9c5e-7b9d1f3a5c7e" }
> ```

### Missions

> **mission.created**
>
> ```json theme={null}
> {
>   "mission_id": "e4a1c3b5-7d9f-4b2a-8c6e-0f1d3a5b7c9e",
>   "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40",
>   "team_id": "3b5d7f9a-1c2e-4a6b-8d0f-2e4a6c8e0b1d",
>   "status": "CREATED"
> }
> ```

> **mission.status\_updated**
>
> `status` is one of `CREATED`, `SENT`, `ACK`, `ON_ROUTE`, `ON_SITE`, `COMPLETED`, `FAILED`. `feedback` is the object sent by the field team, and is absent when none was sent.
>
> ```json theme={null}
> { "mission_id": "e4a1c3b5-7d9f-4b2a-8c6e-0f1d3a5b7c9e", "status": "ON_SITE", "feedback": { "eta_min": 0 } }
> ```
>
> A mission that was not acknowledged in time is set to `FAILED` with `"feedback": { "reason": "no_acknowledgement" }`. Mission outcomes never change the status of the linked event, which stays the delivery status.

Team, mission and device events always carry `"environment": "production"`.

### Reconciliation

> **operation.reconciled and operation.unmatched**
>
> One of your records was checked against the ledger. `operation.reconciled` when it matched or differs, `operation.unmatched` when no operation fits it. See [Reconciliation](/concepts/reconciliation.md).
>
> ```json theme={null}
> {
>   "record_id": "c1a3e5b7-9d0f-4b2a-8c6e-1f3a5c7e9b0d",
>   "source_id": "a4c6e8b0-2d4f-4a6b-8c0e-3b5d7f9a1c2e",
>   "source_kind": "atm_journal",
>   "external_ref": "423118765432",
>   "status": "amount_mismatch",
>   "rule": "reference",
>   "reason": "reference matched, amount 12600 vs 12500",
>   "reason_code": "amount_mismatch",
>   "ledger_id": "e2b4d6f8-0a1c-4e3b-9d5f-7a9c1e3b5d7f",
>   "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40",
>   "amount_minor": 12600,
>   "ledger_amount_minor": 12500,
>   "currency": "EUR"
> }
> ```

> **reconciliation.resolved**
>
> Your organisation recorded a decision about an operation or a record. The note is never sent.
>
> ```json theme={null}
> {
>   "resolution_id": "5f7b9d1a-3c5e-4a7b-9d1f-3b5d7f9a1c3e",
>   "ledger_id": "e2b4d6f8-0a1c-4e3b-9d5f-7a9c1e3b5d7f",
>   "record_id": "c1a3e5b7-9d0f-4b2a-8c6e-1f3a5c7e9b0d",
>   "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40",
>   "code": "corrected_in_client_books",
>   "state": "differs",
>   "client_ref": "CASE-77",
>   "actor_kind": "api_key",
>   "created_at": "2026-10-17T09:12:07.044Z"
> }
> ```
>
> `state` is the state when the decision was recorded: `differs`, `pending`, `missing_external` or `missing_operation`. `actor_kind` is `user` or `api_key`.

> **reconciliation.period\_closed**
>
> A day of a source was closed. Amounts are never sent: read them with `GET /v1/reconciliation/sources/{source_id}/closes/{day}`.
>
> ```json theme={null}
> {
>   "close_id": "9b1d3f5a-7c9e-4b1d-8f3a-5c7e9b1d3f5a",
>   "source_id": "a4c6e8b0-2d4f-4a6b-8c0e-3b5d7f9a1c2e",
>   "source_kind": "atm_journal",
>   "day": "2026-10-16",
>   "timezone": "Europe/Lisbon",
>   "period_start": "2026-10-15T23:00:00.000Z",
>   "period_end": "2026-10-16T23:00:00.000Z",
>   "status": "difference",
>   "differences": 1,
>   "client_ref": "EOD-0416",
>   "actor_kind": "api_key",
>   "created_at": "2026-10-17T06:02:11.418Z"
> }
> ```
>
> `status` is `closed` or `difference`; `differences` is how many currency and direction lines do not agree.

### Service levels (SLA)

Sent only when WEBHOOK is an alert channel of the SLA of the vertical. Counts, times and identifiers only, never the content of an event.

> **sla.violated**
>
> A mission passed the response target of its priority (P1 for `critical`, P2 for `high`, P3 for the rest), counted from the dispatch. Sent once per mission: while it is still open, or when it completes if it finished between two checks (checks run every minute).
>
> ```json theme={null}
> {
>   "mission_id": "e4a1c3b5-7d9f-4b2a-8c6e-0f1d3a5b7c9e",
>   "event_id": "0ee3dfbe-6a0b-4c55-9d0e-2b7f3f1c9a40",
>   "vertical": "healthcare",
>   "priority": "critical",
>   "target_min": 4,
>   "elapsed_min": 10.11,
>   "mission_state": "open"
> }
> ```
>
> `mission_state` is `open` or `completed`; `elapsed_min` is the time from the dispatch when the alert was sent.

> **sla.uptime\_below\_target**
>
> The availability of the wede API this month is below the uptime target of the SLA of the vertical. The availability is the share of the minutes of the month in which the API was running. Sent at most once a month per vertical, after at least 24 hours measured.
>
> ```json theme={null}
> { "vertical": "healthcare", "month": "2026-11", "uptime_pct": 99.812, "target_pct": 99.9 }
> ```

### Test

`webhook.test` is sent only by [`POST /v1/webhooks/:webhook_id/test`](/api-reference/webhooks/test). You cannot subscribe to it.

```json theme={null}
{ "webhook_id": "b7e3c1a9-2d4f-4e6a-8c0b-1f3e5d7a9c2e", "message": "Test delivery from wede" }
```

## Verify the signature

Every delivery is signed with the secret you set when you created the webhook. `X-Wede-Signature` has the form `t=<timestamp>,v1=<signature>`, where `signature` is the hex HMAC-SHA256, keyed with your secret, of the string `<timestamp>.<raw request body>`.

To verify a delivery:

1. Read the raw request body as bytes, before any JSON parsing.
2. Split the header on `,` and read `t` and `v1`.
3. Compute HMAC-SHA256 of `t + "." + raw body` with your secret, as hex.
4. Compare it with `v1` in constant time.
5. Reject the delivery if `t` is too old. We recommend a tolerance of 5 minutes.

```javascript Node.js (Express) theme={null}
import crypto from 'node:crypto'
import express from 'express'

const SECRET = process.env.WEDE_WEBHOOK_SECRET
const TOLERANCE_SECONDS = 300

function verifyWedeSignature(rawBody, header) {
  const parts = Object.fromEntries(
    (header ?? '').split(',').map((p) => {
      const i = p.indexOf('=')
      return [p.slice(0, i), p.slice(i + 1)]
    })
  )
  const t = Number(parts.t)
  if (!Number.isInteger(t) || !parts.v1) return false
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${t}.`)
    .update(rawBody)
    .digest()
  const received = Buffer.from(parts.v1, 'hex')
  return received.length === expected.length && crypto.timingSafeEqual(received, expected)
}

const app = express()

app.post('/webhooks/wede', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyWedeSignature(req.body, req.get('X-Wede-Signature'))) {
    return res.sendStatus(400)
  }
  const delivery = JSON.parse(req.body.toString('utf8'))
  // Answer fast, then process. Deduplicate on delivery.id.
  res.sendStatus(200)
  handleDelivery(delivery)
})
```

```python Python (Flask) theme={null}
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["WEDE_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300

def verify_wede_signature(raw_body: bytes, header: str) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(parts["t"])
        received = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

app = Flask(__name__)

@app.post("/webhooks/wede")
def wede_webhook():
    raw = request.get_data()
    if not verify_wede_signature(raw, request.headers.get("X-Wede-Signature", "")):
        abort(400)
    delivery = request.get_json()
    # Answer fast, then process. Deduplicate on delivery["id"].
    handle_delivery(delivery)
    return "", 200
```

> [!WARNING]
>
> Compute the signature over the raw body exactly as received. Parsing the JSON and serialising it again changes the bytes and the signature will not match.

## Responding and retries

* Answer with any `2xx` status to confirm the delivery. Any other status, a network error or no answer within 10 seconds counts as a failed attempt.
* A failed delivery is retried up to 3 attempts in total: the second attempt about 1 second after the first, the third about 5 seconds after the second. `X-Wede-Attempt` tells you which attempt you are receiving; `X-Wede-Delivery` stays the same.
* Answer quickly and do slow work after responding, so deliveries do not time out.
* Deliveries can arrive more than once and out of order. Deduplicate on the delivery `id` and use `created_at` and the resource's own state (for example [`GET /v1/events/:event_id`](/api-reference/events/get)) when order matters.

### Automatic disabling

Each failed attempt increases the webhook's `failure_count`, and any successful delivery resets it to `0`. After 10 consecutive failed attempts, wede sets the webhook to `active: false` and stops sending to it. Check `active` and `failure_count` with [`GET /v1/webhooks`](/api-reference/webhooks/list). To resume, fix your endpoint and create the webhook again.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.