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/.localhosts are rejected at registration time, in any spelling, including integer and IPv4-mapped IPv6 forms such as2130706433or[::ffff:127.0.0.1]. For local development, use a tunnel. - No redirects. A
3xxcounts 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.