Skip to content

Webhooks

cloudscode posts a JSON event to every URL you register whenever something happens in your tenant — instances spin up, BYOK keys change, subscriptions update, and more. Payloads are HMAC-SHA256 signed using a per-webhook secret so you can verify them.

  1. Open Dashboard → Webhooks → New webhook.
  2. Paste your endpoint URL (must be https://).
  3. Select the events you want to receive.
  4. cloudscode shows your signing secret exactly once — save it now.
  5. Verify the X-CloudsCode-Signature header on every incoming POST.
TypeWhen it fires
instance.createdProvisioning starts for a new Claw instance.
instance.deletedTenant requests teardown.
instance.restartedTenant triggers /instances/:id/restart.
byok.createdA BYOK LLM key is added.
byok.deletedA BYOK LLM key is removed.
tenant.updatedTenant profile / residency changes.
subscription.updatedStripe webhook lands a plan change (cycle, status, etc.).
webhook.testYou clicked Send test on a webhook row.

Every POST has the shape:

{
"id": "evt_8c4f5a2e3b9c1d6e",
"type": "instance.created",
"created_at": 1745961234567,
"tenant_id": "ten_…",
"data": { "id": "inst_…", "subdomain": "acme", "tier": "pro", "status": "provisioning" }
}

id is the idempotency key — receivers should record it and drop dupes. created_at is ms-epoch UTC. data shape is event-specific.

HeaderMeaning
X-CloudsCode-Signaturet=<unix-sec>,v1=<hex-hmac-sha256> — verify this.
X-CloudsCode-Event-IdSame value as id in the body — idempotency key.
X-CloudsCode-Event-TypeMirrors type.
X-CloudsCode-Attempt1 on first delivery, increments on each retry.
X-CloudsCode-Webhook-IdYour webhook id — useful when one app handles many.
User-Agentcloudscode-webhook/1.0

The signature scheme is identical to Stripe’s: HMAC-SHA256 over ${timestamp}.${rawRequestBody} with your webhook secret as the key. Reject any request older than 5 minutes to prevent replay attacks.

import crypto from 'node:crypto';
export function verifyCloudsCodeSig(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=').map((s) => s.trim())),
);
const t = parseInt(parts.t, 10);
const v1 = parts.v1;
if (!t || !v1) return false;
// Replay window: 5 minutes
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
// Constant-time compare
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
// Express example — note: req.rawBody must be set before JSON parsing
app.post('/webhooks/cloudscode', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyCloudsCodeSig(
req.body.toString('utf8'),
req.header('x-cloudscode-signature'),
process.env.CLOUDSCODE_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send('bad signature');
const event = JSON.parse(req.body.toString('utf8'));
// …handle event…
res.status(200).send('ok');
});
import hmac, hashlib, time
def verify_cloudscode_sig(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.strip().split('=', 1) for p in header.split(','))
try:
t = int(parts['t'])
v1 = parts['v1']
except (KeyError, ValueError):
return False
# 5-minute replay window
if abs(time.time() - t) > 300:
return False
expected = hmac.new(
secret.encode('utf-8'),
f'{t}.{raw_body.decode("utf-8")}'.encode('utf-8'),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func VerifyCloudsCodeSig(rawBody []byte, header, secret string) bool {
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
return false
}
if abs(time.Now().Unix()-t) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.%s", t, rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}
func abs(n int64) int64 {
if n < 0 {
return -n
}
return n
}

cloudscode retries failed deliveries up to 5 attempts with this backoff (counted from the initial enqueue):

AttemptDelay
1immediate
2+1 minute
3+5 minutes
4+30 min
5+2 hours
(giving up)+12 hr

A delivery counts as successful when your endpoint replies 2xx. Anything else (including 3xx, 4xx, 5xx, transport errors, timeouts

15s) is a failure.

After 5 trailing failures we automatically disable the webhook to protect your endpoint, and surface a banner in the dashboard. Re-enable from Webhooks → Pause/Resume.

Use X-CloudsCode-Event-Id (same as id in the body) to dedupe — when we retry, the id stays the same so you can SELECT-then-INSERT against an events table. Each retry increments X-CloudsCode-Attempt so you can distinguish first-time deliveries from replays.

The dashboard exposes two debugging primitives:

  • Send test — fires a webhook.test event to a single webhook so you can verify your verification code without waiting for a real event.
  • Replay — re-fires an old delivery (uses the original payload + id, but signs fresh).

Both create a new row in Deliveries so you can compare against the original.

  • HTTPS only. HTTP URLs are rejected at registration time.
  • Per-webhook secret. Rotate by creating a new webhook + deleting the old.
  • Constant-time comparison. Use the SDK helpers above; never compare signatures with ==.
  • 5-minute replay window. Reject any timestamp outside ± 5 min from your server clock.