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

# Webhooks

> Get an HTTPS request when something finishes in Dcycle, instead of polling

A webhook endpoint is an HTTPS URL of yours that Dcycle calls when an event happens in your organization, for
example when a [bulk ingest job](/api-reference/ingest-jobs/get) finishes. Your integration reacts at once instead of
polling.

<Warning>
  **Beta.** The webhooks API is in beta: the contract may still change before general availability. Ignore unknown
  fields in the payload so new ones do not break you.
</Warning>

## How it works

<Steps>
  <Step title="Register an endpoint">
    [`POST /v2/webhook-endpoints`](/api-reference/webhooks/create-endpoint) with your URL and the events you want.
    The response carries the endpoint's **signing secret** (`whsec_…`). Store it: it is shown only once.
  </Step>

  <Step title="Dcycle sends the events">
    Each event is a `POST` with a JSON body, signed with your secret in the `Dcycle-Signature` header.
  </Step>

  <Step title="Answer 2xx quickly">
    Verify the signature, store the event and answer any `2xx` within 10 seconds. Do the heavy work afterwards. Any
    other answer, a timeout or a network error is retried.
  </Step>
</Steps>

Only organization admins can manage endpoints, with an API key or from the app. An endpoint receives the events of
the organization that registered it (the `x-organization-id` it was created with).

Webhooks are for integrations: `ingest_job.finished` is sent for the jobs your organization submits **with an API
key** (their `source` is `api_bulk`). A job a person starts while signed in to Dcycle, such as a waste created from the
app's form, notifies that person in the app instead and sends no webhook (its `source` is `app`).

## Events

| `type` | When | `data` |
| - | - | - |
| `ingest_job.finished` | An ingest job submitted **with an API key** finishes: `completed` or `completed_with_errors`. | The job, exactly as [`GET /v2/ingest-jobs/{id}`](/api-reference/ingest-jobs/get) returns it. |
| `webhook.test` | You call [Send Test Event](/api-reference/webhooks/test-endpoint). Needs no subscription. | `{"endpoint_id": "…"}` |

Every event has the same envelope:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "6f1d2c3b-9a8e-4f7d-b6c5-a4e3d2c1b0a9",
  "type": "ingest_job.finished",
  "created_at": "2026-10-01T10:16:42Z",
  "organization_id": "a8315ef3-dd50-43f8-b7ce-d839e68d51fa",
  "api_version": "2026-10-01",
  "data": {
    "id": "2d4b90c1-7c1e-4b6f-9a55-3f2b8e61d0aa",
    "status": "completed_with_errors",
    "entity_type": "wastes",
    "operation": "create",
    "source": "api_bulk",
    "counts": { "submitted": 250, "succeeded": 247, "failed": 3 },
    "chunks_total": 3,
    "chunks_done": 3,
    "created_at": "2026-10-01T10:15:02.184Z",
    "finished_at": "2026-10-01T10:16:41.902Z",
    "links": {
      "self": "/v2/ingest-jobs/2d4b90c1-7c1e-4b6f-9a55-3f2b8e61d0aa",
      "items": "/v2/ingest-jobs/2d4b90c1-7c1e-4b6f-9a55-3f2b8e61d0aa/items"
    }
  }
}
```

Two ids, two meanings: the envelope `id` is the **event** (the same on every retry: deduplicate on it), and `data.id`
is the **job** the event is about (the `id` you got in the `202` of `POST /v2/wastes`). `data.entity_type` and
`data.operation` say what the job did, e.g. `wastes` + `create`.

`api_version` dates the envelope and payload format. A breaking change to a payload ships under a new version;
new fields can appear without one, so ignore the ones you do not use.

The payload is a summary. Fetch the details you need with the API, e.g. the failed records with
[`GET /v2/ingest-jobs/{id}/items?status=failed`](/api-reference/ingest-jobs/list-items).

### Request headers

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `Dcycle-Webhooks/1.0` |
| `Accept-Encoding` | `identity`: answer uncompressed. Dcycle keeps the start of your answer to help you debug. |
| `Dcycle-Event-Id` | The event `id`. The same on every retry. |
| `Dcycle-Event-Type` | The event `type`. |
| `Dcycle-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |

## Verifying the signature

`v1` is the HMAC-SHA256, keyed with your secret, of the timestamp `t`, a dot, and the **raw** request body. Compute
it over the bytes you received, before parsing the JSON, and compare in constant time. Reject requests whose `t` is
more than 5 minutes old to stop replays.

<CodeGroup>
  ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
  import hashlib
  import hmac
  import time


  def verify(raw_body: bytes, signature_header: str, secret: str, tolerance_s: int = 300) -> bool:
      fields = dict(part.split("=", 1) for part in signature_header.split(","))
      timestamp = fields["t"]
      if abs(time.time() - int(timestamp)) > tolerance_s:
          return False
      expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, fields["v1"])
  ```

  ```javascript JavaScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
  const crypto = require('crypto');

  function verify(rawBody, signatureHeader, secret, toleranceS = 300) {
    const fields = Object.fromEntries(signatureHeader.split(',').map((part) => part.split('=')));
    if (Math.abs(Date.now() / 1000 - Number(fields.t)) > toleranceS) return false;
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${fields.t}.`)
      .update(rawBody) // a Buffer with the exact bytes received
      .digest('hex');
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(fields.v1));
  }
  ```
</CodeGroup>

## Retries and failures

* **At-least-once.** An event can arrive more than once. Deduplicate on `id` (or `Dcycle-Event-Id`).
* **Order is not guaranteed.** Use the payload's timestamps and statuses, not the arrival order.
* **Retry schedule.** An attempt fails on any non-`2xx` answer, a timeout (5 s to connect to each address of your host, 10 s to answer) or a
  network error. Redirects are not followed. Dcycle retries after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h: seven
  attempts over about 21 hours, then the delivery is `failed`.
* **Automatic disabling.** After 5 deliveries in a row end `failed`, the endpoint is disabled
  (`disabled_reason: TOO_MANY_FAILURES`) and its creator is notified in the app. Fix your server and re-enable it
  with [`PATCH`](/api-reference/webhooks/update-endpoint) `{"enabled": true}`.
* **Delivery log.** [List Webhook Deliveries](/api-reference/webhooks/list-deliveries) shows every delivery with what
  was sent (`payload`), what your server answered (`last_status_code` and the start of its body in
  `last_response_body`), why the last attempt failed (`last_error_code`) and the next retry.
  [Retry Webhook Delivery](/api-reference/webhooks/retry-delivery) sends one again on demand, with the same event
  `id`.

## Debugging a failed delivery

`last_error_code` says why the last attempt failed, and `last_error` gives the detail:

| `last_error_code` | What happened | What to check |
| - | - | - |
| `HTTP_ERROR` | Your server answered `4xx` or `5xx`. | `last_status_code` and `last_response_body`: your server's own error message. |
| `REDIRECT_NOT_FOLLOWED` | Your server answered `3xx`. Redirects are not followed. | Register the final URL; `last_error` shows where it pointed. |
| `CONNECT_TIMEOUT` | No connection within 5 seconds to any address of your host (5 s each). | That the server is up and accepts connections from the internet (firewall, IP allow lists). |
| `READ_TIMEOUT` | Connected, but no answer within 10 seconds. | Answer `2xx` first and do the heavy work afterwards. |
| `TLS_ERROR` | The HTTPS handshake failed. | The certificate: expired, self-signed or for another host name. |
| `CONNECTION_ERROR` | The connection was refused or dropped. | That the port is open and the server listens on it. |
| `DNS_ERROR` | The host name does not resolve. | The URL's host name. |
| `NON_PUBLIC_ADDRESS` | The host name resolves to a private address, which Dcycle never calls. | A public DNS record for the host. |
| `REQUEST_ERROR` | Any other error before an answer. | `last_error`. |

`last_response_body` keeps up to the first 2 KB of your server's body. Once the status code has arrived, Dcycle waits at
most 10 more seconds for that body, and the body never changes the outcome. If it looks garbled, your server compressed
it even though Dcycle sends `Accept-Encoding: identity`.
\| `ENDPOINT_DISABLED` | The endpoint was disabled, so the delivery was not attempted again. `last_status_code` and `last_response_body` still show the attempt before. | Re-enable it with [`PATCH`](/api-reference/webhooks/update-endpoint). |

Once fixed, [send a test event](/api-reference/webhooks/test-endpoint) or
[retry the delivery](/api-reference/webhooks/retry-delivery).

## URL requirements

The URL must use `https` and resolve to a **public** address. Private, loopback and link-local addresses (`10.x`,
`192.168.x`, `127.0.0.1`, `169.254.169.254`, …), `localhost` and URLs with credentials are rejected when you
register the endpoint, and the name is resolved again before every request.

## Endpoints

<CardGroup cols={2}>
  <Card title="List Webhook Endpoints" icon="list" href="/api-reference/webhooks/list-endpoints">
    Endpoints of your organization
  </Card>

  <Card title="Get Webhook Endpoint" icon="magnifying-glass" href="/api-reference/webhooks/get-endpoint">
    One endpoint by id
  </Card>

  <Card title="Create Webhook Endpoint" icon="plus" href="/api-reference/webhooks/create-endpoint">
    Register a URL and get its secret
  </Card>

  <Card title="Update Webhook Endpoint" icon="pencil" href="/api-reference/webhooks/update-endpoint">
    Change URL, events or re-enable it
  </Card>

  <Card title="Delete Webhook Endpoint" icon="trash" href="/api-reference/webhooks/delete-endpoint">
    Stop sending to a URL
  </Card>

  <Card title="Rotate Webhook Secret" icon="key" href="/api-reference/webhooks/rotate-secret">
    Replace the signing secret
  </Card>

  <Card title="Send Test Event" icon="paper-plane" href="/api-reference/webhooks/test-endpoint">
    Check your server end to end
  </Card>

  <Card title="List Webhook Deliveries" icon="clock-rotate-left" href="/api-reference/webhooks/list-deliveries">
    What was sent and how it went
  </Card>

  <Card title="Retry Webhook Delivery" icon="rotate" href="/api-reference/webhooks/retry-delivery">
    Send a past event again
  </Card>
</CardGroup>


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