Webhooks

Depllo can POST an event to your own HTTPS endpoint whenever a pipeline or a job finishes — to post to chat, page someone on a failed deploy, record build times, or kick off the next step of a release. Every delivery is signed, retried when your endpoint is down, and logged.

Add an endpoint

In the dashboard: Dashboard → Webhooks → Add endpoint. Give the URL, pick the events (or keep All events), and copy the signing secret (whsec_…) from the dialog — it is shown this once.

Over the API (a session or an sk_live_… API key):

curl -X POST https://depllo.forjio.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer $DEPLLO_TOKEN" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/depllo","events":["depllo.pipeline.finished.v1"]}'

The response carries secret — store it; later reads show only secretPreview. The URL must be https:// and must not point at a private, loopback or link-local address (10.x, 192.168.x, 127.0.0.1, 169.254.169.254, localhost, …); Depllo checks it when you register it and again on every delivery.

events takes event types, prefixes ending in * (depllo.job.*) or * (every event, including ones added later — the default). The CLI and SDKs do the same:

depllo api webhook-endpoints create --url https://example.com/hooks/depllo --events depllo.job.*
depllo api webhook-endpoints list
const { data } = await depllo.webhookEndpoints.create({
  url: "https://example.com/hooks/depllo",
  events: ["depllo.pipeline.finished.v1"],
});
saveSecret(data.secret);

To change an endpoint: PATCH /webhook-endpoints/:id with url, events, description or active (false pauses it). To replace its secret, delete it and add it again.

Events

Type When
depllo.pipeline.finished.v1 A pipeline reached a final status: success, failed or canceled — including a pipeline that failed because its .depllo-ci.yml is invalid (reason: "config_error").
depllo.job.finished.v1 A job finished: success, failed (script failure, timeout, lost runner) or canceled.
depllo.webhook_endpoint.disabled.v1 Depllo switched off one of your endpoints because its deliveries kept failing (see below). Sent to your other endpoints.

GET /api/v1/webhook-endpoints/event-types returns this list.

depllo.pipeline.finished.v1

{
  "id": "evt_01k6…",
  "type": "depllo.pipeline.finished.v1",
  "occurredAt": "2026-10-01T12:04:31.204Z",
  "accountId": "acc_01kx…",
  "data": {
    "pipelineId": "pipe_01k6…",
    "iid": 42,
    "status": "failed",
    "reason": null,
    "ref": "main",
    "sha": "9f2c1e7…",
    "source": "push",
    "prNumber": null,
    "triggeredBy": "webhook",
    "projectId": "proj_01k5…",
    "project": { "id": "proj_01k5…", "name": "web", "repoFullName": "acme/web" },
    "jobs": { "total": 5, "success": 3, "failed": 1, "canceled": 0, "skipped": 1 },
    "createdAt": "2026-10-01T12:01:02.000Z",
    "startedAt": "2026-10-01T12:01:05.000Z",
    "finishedAt": "2026-10-01T12:04:31.000Z",
    "durationSeconds": 206,
    "url": "https://depllo.forjio.com/dashboard/projects/proj_01k5…/pipelines/42"
  }
}

source is push, pull_request, schedule, manual or api. reason is "config_error" when the pipeline failed before running anything because its config is invalid, otherwise null.

depllo.job.finished.v1

{
  "id": "evt_01k6…",
  "type": "depllo.job.finished.v1",
  "occurredAt": "2026-10-01T12:03:58.910Z",
  "accountId": "acc_01kx…",
  "data": {
    "jobId": "job_01k6…",
    "name": "test",
    "stage": "test",
    "status": "failed",
    "exitCode": 1,
    "failureReason": "script_failure",
    "attempt": 1,
    "allowFailure": false,
    "pipelineId": "pipe_01k6…",
    "pipelineIid": 42,
    "ref": "main",
    "sha": "9f2c1e7…",
    "projectId": "proj_01k5…",
    "project": { "id": "proj_01k5…", "name": "web", "repoFullName": "acme/web" },
    "startedAt": "2026-10-01T12:02:10.000Z",
    "finishedAt": "2026-10-01T12:03:58.000Z",
    "durationSeconds": 108,
    "url": "https://depllo.forjio.com/dashboard/projects/proj_01k5…/jobs/job_01k6…"
  }
}

failureReason is script_failure, stuck_timeout, runner_failure, canceled or null. A job retried with retry: sends one event per attempt that finishes.

depllo.webhook_endpoint.disabled.v1

{
  "data": {
    "id": "whe_01k6…",
    "url": "https://example.com/hooks/depllo",
    "description": null,
    "disabledAt": "2026-10-02T13:00:00.000Z",
    "disabledReason": "20 consecutive failed deliveries since 2026-10-01T09:12:44.000Z",
    "consecutiveFailures": 20,
    "failingSince": "2026-10-01T09:12:44.000Z"
  }
}

The request

POST <your url>
Content-Type: application/json
User-Agent: Depllo-Webhooks/1.0
Depllo-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Depllo-Event-Id: evt_01k6…
Depllo-Event-Type: depllo.pipeline.finished.v1
Depllo-Delivery-Id: whd_01k6…
Depllo-Delivery-Attempt: 1

{ "id": "evt_…", "type": "…", "occurredAt": "…", "accountId": "acc_…", "data": { … } }

Answer with any 2xx within 10 seconds. Do the real work after answering (queue it), so a slow job never times the delivery out.

Verify the signature

Depllo-Signature is t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256(secret, "<t>.<raw body>"). Check it against the raw request body, before any JSON parsing (parsing and re-serialising changes the bytes), and refuse a timestamp more than five minutes from now. The SDKs do both:

import express from "express";
import { verifyWebhook } from "@forjio/depllo";

app.post("/hooks/depllo", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = verifyWebhook({
      rawBody: req.body,
      signature: req.header("Depllo-Signature"),
      secret: process.env.DEPLLO_WEBHOOK_SECRET!,
    });
  } catch {
    return res.sendStatus(400);
  }
  if (event.type === "depllo.pipeline.finished.v1") queue(event);
  res.sendStatus(204);
});
from forjio_depllo import DeplloError, verify_webhook

@app.post("/hooks/depllo")
def depllo_hook():
    try:
        event = verify_webhook(
            raw_body=request.get_data(),
            signature=request.headers.get("Depllo-Signature"),
            secret=os.environ["DEPLLO_WEBHOOK_SECRET"],
        )
    except DeplloError:
        return "", 400
    queue(event)
    return "", 204
body, _ := io.ReadAll(r.Body)
event, err := depllo.VerifyWebhook(body, r.Header.Get("Depllo-Signature"), os.Getenv("DEPLLO_WEBHOOK_SECRET"), 0)
if err != nil {
	http.Error(w, "bad signature", http.StatusBadRequest)
	return
}
if event.Type == depllo.EventPipelineFinished { /* json.Unmarshal(event.Data, &payload) */ }
w.WriteHeader(http.StatusNoContent)

Without an SDK:

const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
  && Math.abs(Date.now() / 1000 - Number(t)) <= 300;

Retries

Anything other than a 2xx within 10 seconds — another status, a redirect (never followed), a timeout, a refused connection — is a failed attempt. Depllo retries it 1 minute, 5 minutes, 25 minutes, 2 hours and 12 hours later: 6 attempts in all. After the sixth the delivery is failed and stays so until you retry it.

Each endpoint is retried on its own, and every attempt carries the same body and the same Depllo-Event-Id. Delivery is at least once: a retry after a slow 2xx can arrive twice, so drop events whose id you have already handled. Order is not guaranteed — use occurredAt (and the job/pipeline ids) when it matters.

Endpoints that keep failing

When an endpoint has failed 20 attempts in a row and has been failing for at least 24 hours, Depllo switches it off: active: false, with disabledAt and disabledReason. Its queued deliveries become failed, a depllo.webhook_endpoint.disabled.v1 event goes to your other endpoints, and the audit log records webhook_endpoint.auto_disabled. Both conditions must hold, so a short outage never switches anyone off. The endpoint list shows the streak as it builds (consecutiveFailures, failingSince).

Fix the receiver, turn the endpoint back on (the dashboard's Active switch, or PATCH /webhook-endpoints/:id {"active": true}, which also clears the streak), then retry what it missed.

The delivery log

Dashboard → Webhooks lists recent deliveries; open one to see every attempt (status, answer, time taken) and the exact body sent, and Retry it. Over the API:

  • GET /webhook-deliveries — newest first; filter with endpointId, status (pending, succeeded, failed) and type; page with limit (1–100, default 20) and meta.cursor while meta.hasMore.
  • GET /webhook-deliveries/:id — one delivery with its attemptLog.
  • POST /webhook-deliveries/:id/retry — one more attempt now (202). 409 ALREADY_QUEUED when it is already queued, 409 ENDPOINT_DISABLED when its endpoint is off.
depllo api webhook-deliveries list --status failed
depllo api webhook-deliveries retry whd_01k6…

Deliveries are kept for 30 days after they finish.