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 withendpointId,status(pending,succeeded,failed) andtype; page withlimit(1–100, default 20) andmeta.cursorwhilemeta.hasMore.GET /webhook-deliveries/:id— one delivery with itsattemptLog.POST /webhook-deliveries/:id/retry— one more attempt now (202).409 ALREADY_QUEUEDwhen it is already queued,409 ENDPOINT_DISABLEDwhen 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.