opencloudportDocs

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:

HeaderWhat it is
opencloudport-signaturet=<unix seconds>,v1=<signature>: check it before trusting the body (below).
opencloudport-eventThe event type, so you can route without parsing.
opencloudport-deliveryThis 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.

TypeWhen
deployment.succeededNew code went live on an app.
deployment.failedA deployment didn’t go live. data.error says why.
email.deliveredAn app’s email reached the recipient’s mail server.
email.bouncedAn app’s email bounced: the address doesn’t exist or won’t take mail.
email.complainedThe recipient marked an app’s email as spam.
email.failedAn app’s email couldn’t be sent or delivered.
balance.lowThe workspace balance went below its low-balance warning.
workspace.out_of_creditThe balance reached $0. Apps pause at data.pauses_at unless funds are added.
workspace.pausedThe workspace’s apps were paused because it ran out of credit.
workspace.resumedFunds were added and the workspace’s apps run again.
app.pausedAn app was paused. data.paused_by is owner, limit or workspace.
app.resumedA paused app runs again.
app.limit_reachedAn app reached its monthly spending limit (it pauses until next month or a higher limit).
end_user.limit_reachedAn end user reached their monthly spending limit; their new work is refused.
sandbox.stoppedA sandbox stopped. data.reason is asleep (idle) or stopped.
domain.activeA custom domain points here and serves the app, with its certificate.
domain.failedA 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.

MethodPath
GET/api/v1/webhooksList 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/:idOne webhook. PATCH { url?, description?, events?, enabled? }; turning it back on clears a failing streak.
POST/api/v1/webhooks/:id/rotate-secretA new secret (in the answer, once). The old one stops working at once.
POST/api/v1/webhooks/:id/testSend a webhook.test now; the answer says how it went.
GET/api/v1/webhooks/:id/deliveries?limit=50What was sent, newest first.
POST/api/v1/webhooks/:id/deliveries/:delivery_id/redeliverSend 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.

On this page