Webhooks

Register an HTTPS endpoint and Sarkoa will POST a signed event when something happens in your organization, such as a file finishing optimization, a share link being created, or a quota band being crossed.

This replaces polling. Without webhooks, every integration has to ask "is it ready yet?" on a timer, which burns request quota and adds latency equal to your poll interval. With them, Sarkoa tells you, usually within a minute.

Webhooks are available on Essential plans and above.

Set up

Create an endpoint from the CLI:

sarkoa webhook create https://example.com/hooks/sarkoa \
  --event asset.processing.succeeded \
  --event asset.processing.failed

Or from an AI agent over MCP with create_webhook, or directly:

POST /api/org/{orgId}/webhooks
{ "url": "https://example.com/hooks/sarkoa", "eventTypes": ["asset.processing.succeeded"] }

Use ["*"] to subscribe to everything, including event types added later.

The response contains a signing secret, shown exactly once:

  signing secret: whsec_a1b2c3…
  Store it now. It is not retrievable later.

Lost it? sarkoa webhook rotate <webhook-id> issues a new one. Rotation takes effect immediately with no overlap window, so update your receiver first.

Registering a URL that already exists updates its subscription rather than creating a duplicate, which makes a re-run CI bootstrap safe.

Events

Event Fires when
asset.processing.succeeded A file finished cloud optimization and is ready to view
asset.processing.failed Cloud optimization failed for a file
asset.deleted A file was deleted or purged past its retention window
share.created A public share link was created
share.revoked A public share link was revoked
map.published A custom map became publicly reachable
series.entry.appended A new frame landed in a time series
quota.threshold.reached Your organization crossed a storage or tile usage band

sarkoa webhook list --events prints the current catalogue.

Payload

{
  "id": "3f9c1a2e-…",
  "type": "asset.processing.succeeded",
  "createdAt": "2026-08-19T14:22:05.331Z",
  "organizationId": "8b21…",
  "data": {
    "assetId": "c47d…",
    "jobId": "91ab…",
    "name": "parcels.geojson",
    "cloudFormat": "pmtiles",
    "sizeBytes": 4823104,
    "bbox": [-79.6, 43.5, -79.1, 43.9]
  }
}

Headers on every delivery:

Header Meaning
Sarkoa-Signature t=<unix>,v1=<hex hmac-sha256>
Sarkoa-Event-Id Stable per logical event; use it to deduplicate
Sarkoa-Event-Type Same as type in the body
Sarkoa-Delivery-Id This delivery attempt's row id
Sarkoa-Delivery-Attempt 1-based attempt counter

Share tokens and credentials are deliberately never included in a payload, because an event body lands in logs and third-party queues.

Verifying the signature

The signature covers `${timestamp}.${rawBody}`, HMAC-SHA256 with your signing secret. The timestamp is inside the signed string, so a captured delivery cannot be replayed indefinitely. Reject anything older than 300 seconds.

Verify against the raw request body. Re-serializing a parsed object will not match, because key order and number formatting are not guaranteed to round-trip.

Node / TypeScript

Uses Web Crypto, which is built into Node 18+, Deno, and Bun. No dependencies.

const TOLERANCE_SECONDS = 300;

async function verify(secret: string, rawBody: string, header: string | null) {
  if (!header) return false;

  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=', 2) as [string, string]),
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const mac = await crypto.subtle.sign(
    'HMAC',
    key,
    encoder.encode(`${timestamp}.${rawBody}`),
  );
  const expected = [...new Uint8Array(mac)]
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');

  // Constant-time compare. A plain === leaks timing information.
  if (expected.length !== parts.v1.length) return false;
  let diff = 0;
  for (let i = 0; i < expected.length; i++) {
    diff |= expected.charCodeAt(i) ^ parts.v1.charCodeAt(i);
  }
  return diff === 0;
}

export async function POST(request: Request) {
  // Read the body as text first. Parsing and re-serializing will not match the signature.
  const rawBody = await request.text();
  const ok = await verify(
    process.env.SARKOA_WEBHOOK_SECRET!,
    rawBody,
    request.headers.get('Sarkoa-Signature'),
  );
  if (!ok) {
    // Never 200 an unverified delivery. That tells Sarkoa it landed and stops the retries.
    return new Response('invalid signature', { status: 400 });
  }

  const event = JSON.parse(rawBody);
  if (event.type === 'asset.processing.succeeded') {
    await onAssetReady(event.data);
  }
  return new Response('ok');
}

Python

import hashlib
import hmac
import time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature:
        return False
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

Use hmac.compare_digest, not ==. A plain comparison leaks timing information.

Retries

Return any 2xx within 10 seconds. If your work takes longer, return 202 immediately and process asynchronously.

Anything else is a failure and is retried with backoff:

10s β†’ 1m β†’ 5m β†’ 30m β†’ 2h β†’ 6h

After six attempts the delivery is parked as exhausted. After 20 consecutive failures across deliveries, the endpoint is auto-disabled and the reason is recorded. Check sarkoa webhook list to confirm, then fix the receiver and re-register.

A redirect (3xx) counts as a failure rather than being followed. A webhook receiver that redirects is a misconfiguration, and following it would deliver signed data to a destination that was never verified.

Testing and debugging

Send a real, signed delivery on demand and see exactly what your endpoint returned:

sarkoa webhook test <webhook-id>
# Delivered. Endpoint returned HTTP 200.

The test payload has type webhook.test, so a receiver can branch on it and skip real side effects.

When someone says "I never got the event", the delivery log has the answer:

sarkoa webhook deliveries --status exhausted
sarkoa webhook deliveries --webhook-id <id> --limit 100

Each row shows the status, the HTTP code your endpoint returned, the attempt count, the error text, and when the next attempt is scheduled.

Idempotency

Deliveries are at-least-once. A retried optimization callback, or a network timeout after your server already committed, can both produce a second delivery of the same event.

Deduplicate on Sarkoa-Event-Id (equivalently, id in the body). It is stable for one logical event across every retry and every subscribed endpoint.

Requirements

  • HTTPS only. Payloads are signed but not encrypted at the application layer.
  • Publicly reachable. Loopback, RFC 1918, link-local, and .internal / .local hosts are rejected at registration time, in any spelling, including integer and IPv4-mapped IPv6 forms such as 2130706433 or [::ffff:127.0.0.1]. For local development, use a tunnel.
  • No redirects. A 3xx counts as a failed delivery rather than being followed, so the destination is always the endpoint you registered. Point the URL at its final location.
  • At most 10 endpoints per organization.