Webhooks
Hear about things as they happen (a deploy went live, an email bounced, the balance ran low) instead of checking.
A webhook is an address on your own server that we send events to. Add one under Manage → Webhooks in the dashboard, with the API, the SDK or your agent (create_webhook). A workspace can have up to 10.
What gets sent
Each event is a POST with a JSON body:
{
"id": "evt_…",
"type": "deployment.succeeded",
"created_at": "2026-10-22T19:31:31.820Z",
"workspace": { "id": "…" },
"data": { "app_id": "app_…", "deployment_id": "dep_…" }
}and these headers:
| Header | What it is |
|---|---|
opencloudport-signature | t=<unix seconds>,v1=<signature>: check it before trusting the body (below). |
opencloudport-event | The event type, so you can route without parsing. |
opencloudport-delivery | This delivery's id. A retried delivery keeps its id, so use it (or the event's id) to skip repeats. |
Answer with any 2xx within 10 seconds. Do slow work after answering.
Events
You can take every event (new kinds are added over time) or pick the ones you want.
| Type | When |
|---|---|
deployment.succeeded | New code went live on an app. |
deployment.failed | A deployment didn’t go live. data.error says why. |
email.delivered | An app’s email reached the recipient’s mail server. |
email.bounced | An app’s email bounced: the address doesn’t exist or won’t take mail. |
email.complained | The recipient marked an app’s email as spam. |
email.failed | An app’s email couldn’t be sent or delivered. |
balance.low | The workspace balance went below its low-balance warning. |
workspace.out_of_credit | The balance reached $0. Apps pause at data.pauses_at unless funds are added. |
workspace.paused | The workspace’s apps were paused because it ran out of credit. |
workspace.resumed | Funds were added and the workspace’s apps run again. |
app.paused | An app was paused. data.paused_by is owner, limit or workspace. |
app.resumed | A paused app runs again. |
app.limit_reached | An app reached its monthly spending limit (it pauses until next month or a higher limit). |
end_user.limit_reached | An end user reached their monthly spending limit; their new work is refused. |
sandbox.stopped | A sandbox stopped. data.reason is asleep (idle) or stopped. |
domain.active | A custom domain points here and serves the app, with its certificate. |
domain.failed | A custom domain couldn’t be set up. data.problem says why. |
There's also webhook.test, sent only when you press Send test (or call POST /api/v1/webhooks/:id/test).
Checking a delivery
The signature is an HMAC-SHA256, keyed with the webhook's secret (whsec_…, shown once when you add the webhook or make a new one), of the timestamp, a dot, and the raw body: <t>.<body>. Check it against the body exactly as received, before parsing it, and refuse signatures more than 5 minutes old so an old delivery can't be replayed.
In any runtime with Web Crypto (Workers, Deno, Bun, Node 20+):
/** Whether a delivery really came from opencloudport, and is recent. */
async function verifyWebhook(body: string, header: string | null, secret: string) {
const t = /t=(\d+)/.exec(header ?? '')?.[1]
const v1 = /v1=([0-9a-f]+)/.exec(header ?? '')?.[1] ?? ''
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${body}`)))
const expected = Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('')
// Compare every character, so the time taken doesn't give the answer away.
let diff = v1.length ^ expected.length
for (let i = 0; i < expected.length; i++) diff |= (v1.charCodeAt(i) || 0) ^ expected.charCodeAt(i)
return diff === 0
}
export async function POST(request: Request) {
const body = await request.text()
if (!(await verifyWebhook(body, request.headers.get('opencloudport-signature'), process.env.OCP_WEBHOOK_SECRET!))) {
return new Response('Bad signature', { status: 400 })
}
const event = JSON.parse(body)
// … handle event.type
return new Response('ok')
}With Node's own crypto module:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(body, header, secret) {
const t = /t=(\d+)/.exec(header ?? '')?.[1]
const v1 = /v1=([0-9a-f]+)/.exec(header ?? '')?.[1] ?? ''
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
}Once the SDK is published
@opencloudport/sdk will export the same check as verifyWebhook(body, header, secret). It isn’t on npm yet, so copy the function above for now.
When a delivery fails
Anything but a 2xx within 10 seconds counts as failed. We try again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours (about a day and a half in all), then give up on that event. Each try is in the webhook's delivery log, with what your server answered; you can send any of them again.
A webhook whose deliveries keep failing for 3 days in a row is turned off, and the dashboard says why. Fix the address or your server, then turn it back on. Events and deliveries are kept 30 days.
The API
All workspace keys; app keys can't reach webhooks.
| Method | Path | |
|---|---|---|
GET | /api/v1/webhooks | List them. Secrets aren't shown again; secret_hint tells them apart. |
POST | /api/v1/webhooks | { url, description?, events? }: events is "*" (default) or a list of types. The answer has secret, once. |
GET / PATCH / DELETE | /api/v1/webhooks/:id | One webhook. PATCH { url?, description?, events?, enabled? }; turning it back on clears a failing streak. |
POST | /api/v1/webhooks/:id/rotate-secret | A new secret (in the answer, once). The old one stops working at once. |
POST | /api/v1/webhooks/:id/test | Send a webhook.test now; the answer says how it went. |
GET | /api/v1/webhooks/:id/deliveries?limit=50 | What was sent, newest first. |
POST | /api/v1/webhooks/:id/deliveries/:delivery_id/redeliver | Send that event again. |
curl -X POST "https://opencloudport.com/api/v1/webhooks" \
-H "authorization: Bearer $OCP_KEY" -H "content-type: application/json" \
-d '{"url": "https://example.com/webhooks/opencloudport", "events": ["deployment.failed", "email.bounced"]}'Once the SDK is published: ocp.webhooks.create / list / get / update / delete / rotateSecret / test / deliveries / redeliver, and verifyWebhook.
Webhooks only go to https addresses on the public internet.
opencloudport