Receiving platform webhooks (consumer guide)
How an external app (viite.ai first; trading next) receives outbound
*.published webhooks from the platform, verifies them, and acts. Pairs with the
sender: apps/api/webhook/ and the SDK client.webhooks.
The request you receive
Section titled “The request you receive”On a matching domain event the platform sends an HTTP POST to your registered
URL:
| Header | Example | Meaning |
|---|---|---|
Content-Type | application/json | Body is JSON. |
X-Platform-Event | blog.published | The event type. |
X-Platform-Event-Id | evt_… | Unique event id (use to dedupe). |
X-Platform-Signature | sha256=<hex> | HMAC-SHA256 of the raw body with your subscription secret. |
Body (thin snapshot — pull the full entity from the public API if you need more):
{ "eventId": "evt_…", "eventType": "blog.published", "occurredAt": "2026-06-14T12:00:00.000Z", "aggregateId": "art_…", "data": { "id": "art_…", "channel": "blog", "userId": "…", "slug": "my-post" }}Verifying the signature
Section titled “Verifying the signature”Recompute the HMAC over the exact raw body bytes (do not re-serialize parsed
JSON — key order/whitespace would differ) and compare in constant time. This is
the mirror of apps/api/webhook/signing.ts.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyPlatformSignature(secret: string, rawBody: string, header: string): boolean { const expected = `sha256=${createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}`; const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(header, 'utf8'); if (a.length !== b.length) return false; return timingSafeEqual(a, b);}Reference endpoint (verify → trigger rebuild)
Section titled “Reference endpoint (verify → trigger rebuild)”viite.ai is a static site, so the action is “rebuild myself”: verify, then call
your own deploy hook (Coolify/Netlify). Env: WEBHOOK_SIGNING_SECRET (the secret
returned once at subscription creation) and DEPLOY_HOOK_URL (your host’s build
hook).
Astro (src/pages/api/webhooks/blog.ts)
Section titled “Astro (src/pages/api/webhooks/blog.ts)”import type { APIRoute } from 'astro';import { createHmac, timingSafeEqual } from 'node:crypto';
export const prerender = false; // server route, not statically built
export const POST: APIRoute = async ({ request }) => { const raw = await request.text(); const sig = request.headers.get('X-Platform-Signature') ?? ''; const secret = import.meta.env.WEBHOOK_SIGNING_SECRET ?? process.env.WEBHOOK_SIGNING_SECRET ?? '';
const expected = `sha256=${createHmac('sha256', secret).update(raw, 'utf8').digest('hex')}`; const a = Buffer.from(expected), b = Buffer.from(sig); if (a.length !== b.length || !timingSafeEqual(a, b)) { return new Response('bad signature', { status: 401 }); }
// Only act on the events you care about. if (request.headers.get('X-Platform-Event') === 'blog.published') { const hook = import.meta.env.DEPLOY_HOOK_URL ?? process.env.DEPLOY_HOOK_URL; if (hook) await fetch(hook, { method: 'POST' }); // fire the rebuild } return new Response('ok', { status: 200 }); // ack fast; non-2xx makes us retry};Generic Node/Bun handler
Section titled “Generic Node/Bun handler”export async function handlePlatformWebhook(req: Request): Promise<Response> { const raw = await req.text(); if (!verifyPlatformSignature(process.env.WEBHOOK_SIGNING_SECRET!, raw, req.headers.get('X-Platform-Signature') ?? '')) return new Response('bad signature', { status: 401 }); if (req.headers.get('X-Platform-Event') === 'blog.published') await fetch(process.env.DEPLOY_HOOK_URL!, { method: 'POST' }); return new Response('ok', { status: 200 });}Notes:
- Respond 2xx fast. Any non-2xx (or a timeout > 10s) is treated as failure and
retried by the sender (
retryLimit: 5, exponential backoff). Do the rebuild fire-and-forget; don’t block the response on it. - Idempotency: the same event id can arrive more than once (retry after your
ack was lost). Dedupe on
X-Platform-Event-Idif a double rebuild matters.
Registering the subscription (one-time)
Section titled “Registering the subscription (one-time)”The secret is generated by the platform and shown once at creation — so register first, then put the returned secret into your receiver’s env.
# 1. Register against prod (api key OR session). Save the `secret` from the response.curl -sS -X POST https://platform.viite.ai/api/webhooks/subscriptions \ -H "Authorization: Bearer $PLATFORM_API_KEY" \ -H 'Content-Type: application/json' \ -d '{"eventType":"blog.published","url":"https://viite.ai/api/webhooks/blog","description":"viite.ai rebuild"}'# → { "data": { "id": "whsub_…", "secret": "whsec_…", ... } }
# 2. Set WEBHOOK_SIGNING_SECRET=<that secret> + DEPLOY_HOOK_URL=<your build hook> in viite.ai env; deploy.# 3. Publish a blog post, then check deliveries:curl -sS https://platform.viite.ai/api/webhooks/deliveries \ -H "Authorization: Bearer $PLATFORM_API_KEY" # status should be "success"Or via the SDK:
const { secret } = await client.webhooks.create({ eventType: 'blog.published', url: 'https://viite.ai/api/webhooks/blog', description: 'viite.ai rebuild',});// store `secret` in viite.ai env — it is not retrievable againTo rotate the secret: delete the subscription and create a new one (the secret is immutable), then update the receiver env.
