Skip to content
KrevayadocsGet help Open Krevaya

Verify signed webhooks and handle retries

Receive post.published, post.failed, and competitor.breakout events. Verify the raw-body HMAC signature and build a receiver that handles duplicate delivery.

Updated

Receive publication outcomes

Krevaya sends an HTTP POST to each matching active webhook endpoint. The body contains event plus a post or competitor object. Use post.published to record successful publication and post.failed to start a review workflow.

EventPayload
post.publishedpost: id, platform, status, external_post_id, permalink_url, error, body_preview
post.failedThe same post snapshot, including its error
competitor.breakoutcompetitor: platform, handle, permalink, engagement measures, baseline, ratio, caption, and event-specific metadata

body_preview is at most 140 characters. IDs are strings. permalink_url can be null, including for some Story results; do not require a URL to recognize a successful event.

Register an endpoint with session authentication

POST /webhooks accepts url and optional events. It requires a signed-in dashboard session and API/webhook plan access. A key cannot create, list, or delete webhook registrations. The URL must resolve to a public destination. Use HTTPS for your receiver.

json
{
  "url": "https://your-app.example/hooks/krevaya",
  "events": ["post.published", "post.failed"]
}

The 201 response includes id, url, events, and secret. Store secret immediately; GET /webhooks does not reveal it again. Up to five endpoints are allowed. Omitting events subscribes the endpoint to all supported events. DELETE /webhooks/{id} removes an endpoint and returns 204.

Verify the exact bytes before parsing JSON

The X-Krevaya-Signature header is sha256=<hex digest>. Compute HMAC-SHA256 using the webhook secret's UTF-8 bytes as the key and the unchanged request body bytes as the message. Compare in constant time. Do not decode the hex-looking secret into binary, reserialize parsed JSON, normalize whitespace, or verify a body changed by middleware.

javascript
// Node.js 20+, on a persistent POSIX filesystem (Linux/macOS).
// Save as webhook.mjs. Set KREVAYA_WEBHOOK_SECRET; put TLS in front.
import http from 'node:http';
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { link, mkdir, open, unlink } from 'node:fs/promises';
import { resolve } from 'node:path';

const secret = process.env.KREVAYA_WEBHOOK_SECRET;
if (!secret) throw new Error('Set KREVAYA_WEBHOOK_SECRET');
const directory = resolve('./webhook-receipts');
await mkdir(directory, { recursive: true, mode: 0o700 });

async function persistReceipt(raw) {
  const digest = createHash('sha256').update(raw).digest('hex');
  const temporary = `${directory}/.${randomUUID()}.tmp`;
  const destination = `${directory}/${digest}.json`;
  let file;
  try {
    file = await open(temporary, 'wx', 0o600);
    await file.writeFile(raw);
    await file.sync();
    await file.close();
    file = undefined;
    try {
      // Publish only a complete file, without replacing an existing receipt.
      await link(temporary, destination);
    } catch (error) {
      if (error.code !== 'EEXIST') throw error;
    }
    const folder = await open(directory, 'r');
    try { await folder.sync(); } finally { await folder.close(); }
  } finally {
    if (file) await file.close();
    await unlink(temporary).catch(() => {});
  }
}

http.createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/hooks/krevaya') {
    res.writeHead(404).end(); return;
  }
  try {
    const chunks = [];
    let size = 0;
    for await (const chunk of req) {
      size += chunk.length;
      if (size > 1_000_000) { res.writeHead(413).end(); return; }
      chunks.push(chunk);
    }
    const raw = Buffer.concat(chunks);
    const supplied = req.headers['x-krevaya-signature'];
    if (typeof supplied !== 'string' || !/^sha256=[0-9a-f]{64}$/.test(supplied)) {
      res.writeHead(401).end(); return;
    }
    const expected = createHmac('sha256', secret).update(raw).digest();
    const actual = Buffer.from(supplied.slice(7), 'hex');
    if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
      res.writeHead(401).end(); return;
    }
    JSON.parse(raw.toString('utf8')); // Parse only after verification.
    await persistReceipt(raw); // A separate worker processes completed .json files.
    res.writeHead(204).end();
  } catch (error) {
    res.writeHead(error instanceof SyntaxError ? 400 : 503).end();
  }
}).listen(8080, '127.0.0.1');

This local receiver writes and syncs a temporary file before atomically publishing a completed receipt. A failed write is never acknowledged as an existing event on retry. Run it on persistent storage that supports POSIX file links and directory syncing; a separate worker should read only the .json receipts. Retain receipts and record processing state so a redelivery does not repeat a side effect. For multiple application instances or serverless hosting, use a durable shared queue or a database transaction with a unique receipt key instead.

Acknowledge promptly and make processing idempotent

Krevaya makes up to three delivery attempts. The first is immediate; later attempts wait 2 seconds and 8 seconds. Network errors, HTTP 5xx, and HTTP 429 are retried. Other 4xx responses are final. Each outbound attempt has a five-second timeout.

Return a 2xx response after safely accepting the event, then perform slow work asynchronously. Do not rely on a redirect to deliver the payload. If your durable handoff is unavailable, return 503 so a retry can help.

There is currently no signed timestamp or unique delivery ID in the webhook contract. Signature verification authenticates bytes, not their freshness. For publication events, make downstream actions idempotent using event plus post.id and your stored processing state. A body hash can identify an exact retry; do not invent a delivery header.

Reconcile after an outage

Webhooks are notifications with a finite retry window. They are not a guaranteed archive of every event. After a receiver outage, reconcile the posts your integration tracks through MCP get_post or a status-filtered post list. Store the last known status and external_post_id alongside your own record.

Frequently asked questions

Why does signature verification fail after parsing JSON?

The signature covers the original request bytes. Parsing and reserializing can change whitespace or property order. Capture and verify the raw body first.

How many times does Krevaya retry a webhook?

Up to three attempts for network errors, HTTP 5xx, or 429. The later attempts wait 2 and 8 seconds. Other 4xx responses are final.