Webhooks
Webhooks are the recommended way to find out when something happens on your account. Instead of polling our API, we POST a JSON event to a URL you control.
Event types
You can subscribe to any combination of these:
| Event | Triggered when |
|---|---|
checkout.completed | A customer successfully paid through a checkout session. |
checkout.failed | A payment attempt failed (declined card, fraud rule, 3DS abandoned). |
payout.sent | The crypto payout was broadcast to the network. |
payout.failed | The payout couldn't be completed (network issue, invalid address). |
merchant.updated | Your merchant settings changed (wallet address, plan, etc.). |
Register a webhook
Add an endpoint from the webhooks dashboard, or via the API:
curl https://api.skiro.io/v1/webhooks \
-H "Authorization: Bearer $SKIRO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yoursite.com/api/skiro-webhook",
"events": ["checkout.completed", "checkout.failed"]
}'The response includes id and a one-time secret. Save the secret: it's how you verify signatures.
Event payload
Every webhook delivery has the same envelope shape:
{
"id": "evt_8f2a91c...",
"type": "checkout.completed",
"created_at": "2026-05-29T14:32:00Z",
"data": {
// The full transaction or payout object
}
}For checkout.completed, the data field contains the full transaction object: including the metadata you attached when creating the session.
Verifying signatures
Every request includes an X-Skiro-Signature header in the form t=<unix_timestamp>,v1=<hex_signature>. Compute HMAC-SHA256 of `${t}.${raw_body}` with your webhook secret and constant-time compare to v1:
import { createHmac, timingSafeEqual } from 'crypto'
export async function POST(req) {
const raw = await req.text()
const sig = req.headers.get('x-skiro-signature') || ''
const m = sig.match(/t=(\d+),v1=([a-f0-9]+)/)
if (!m) return new Response('bad signature', { status: 401 })
const [, t, v1] = m
const expected = createHmac('sha256', process.env.SKIRO_WEBHOOK_SECRET)
.update(`${t}.${raw}`)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(v1)
const ok = a.length === b.length && timingSafeEqual(a, b)
if (!ok) return new Response('bad signature', { status: 401 })
const event = JSON.parse(raw)
// Process the event
return new Response('ok')
}Reject signatures whose timestamp is more than five minutes old to prevent replay attacks.
Retries and delivery
If your endpoint returns a non-2xx status code or doesn't respond within 10 seconds, we retry. The retry schedule is:
- 1st retry: 30 seconds later
- 2nd retry: 5 minutes later
- 3rd retry: 30 minutes later
- 4th retry: 2 hours later
- 5th retry: 12 hours later
After 5 failed attempts (about 14 hours total), we mark the delivery as permanently failed. You can replay any delivery from the dashboard.
Best practices
- Respond quickly. Return 200 immediately, then process the event in the background. Long-running webhook handlers cause retry storms.
- Use idempotency. The same event may be delivered more than once if your endpoint times out and we retry. Use
event.idto deduplicate. - Verify signatures. Don't skip this. Anyone can POST to a public URL.
- Log everything. Keep records of webhook deliveries for at least 30 days. The dashboard has a built-in delivery log.
- Use HTTPS. We won't deliver to plain HTTP endpoints in live mode.