Guide to Skovik Webhooks: Features and Best Practices
Skovik is an operations platform with a public API for resources such as users, branches and export orders. Its webhooks tell your systems when one of those changes. They are deliberately thin: each delivery names the event and links to the resource, and you fetch the current state from the API.
This guide explains Skovik's notification format and its two IDs, the ten events, endpoint setup, signature checks and how secret regeneration can catch you out.
What are Skovik webhooks?
Skovik webhooks are HTTPS endpoints you register in the settings UI. For each subscribed event Skovik sends a JSON POST with Content-Type: application/json, the user agent Skovik-Webhook (+https://developer.skovik.com), and Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature. The webhooks overview and the receiving guide describe the format.
The body follows JSON:API conventions. The changed resource isn't embedded; data.relationships.related_ gives its type and ID and a links.related path to fetch it. That keeps payloads small and means you always act on the latest state, at the cost of one API call per event.
Skovik webhook features
| Feature | Details |
|---|---|
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | webhook-signature |
| Body | JSON |
| Event routing | Body field type |
| Payload style | Thin JSON:API notification linking to the resource |
| Configuration | Settings UI, HTTPS only |
| Secret | whsec_ plus base64, one per endpoint |
| Regeneration | Old secret stops working immediately |
| IDs | webhook-id per delivery; data.id per event |
| Retries | Exponential backoff with jitter over roughly three days |
| Event lookup | GET /v1/webhook_ |
Events and payloads
Skovik documents ten events:
| Event | Triggered When | Common Use Cases |
|---|---|---|
users.created | A user is created | Provision the user in your system |
users.updated | Some part of the user object changed | Re-fetch the user and sync |
users.archived | A user is archived | Deprovision the user |
export_orders.created | An export order is created | Track export requests |
export_orders.completed | An export order completed successfully | Fetch the order to get the export file URL |
export_orders.failed | An export order failed | Fetch the order and read error_message |
branches.created | A branch is created | Sync branches |
branches.updated | A branch changed | Re-fetch the branch and sync |
branches.archived | A branch is archived | Remove or flag the branch |
misc.ping | A test is triggered from the settings UI | Check that the endpoint verifies and answers 2xx |
New types may be added, so ignore ones you don't recognise. A branches.created delivery from the webhook samples library:
{
"id": "2a9f8e7d6c5b4a3928170615",
"type": "branches.created",
"timestamp": "2026-05-25T14:30:45Z",
"data": {
"id": "9d8c7b6a5e4f3a2b1c0d9e8f",
"type": "webhook_events",
"attributes": {
"created_at": "2026-05-25T14:30:45Z",
"name": "branches.created"
},
"links": {
"self": "/v1/webhook_events/9d8c7b6a5e4f3a2b1c0d9e8f"
},
"relationships": {
"related_object": {
"data": {
"type": "branches",
"id": "6b5a4938271605f4e3d2c1b0"
},
"links": {
"related": "/v1/branches/6b5a4938271605f4e3d2c1b0"
}
}
}
}
}
| Field | Meaning |
|---|---|
id | Delivery ID, equal to the webhook-id header |
type | The event name. data.attributes.name repeats it, and data.type is always webhook_events |
timestamp | When this delivery attempt was signed and sent |
data.id | The event ID, the same for every endpoint that receives the event |
data.relationships.related_ | The related resource (type, id) and a links.related path to fetch it. Absent for events with no resource, such as misc.ping |
GET /v1/webhook_ (with read access to webhook_events) returns an event exactly as Skovik sent it.
Setting up Skovik webhooks
- Open the webhook settings in Skovik and add an endpoint with an HTTPS URL, such as
https://webhooks.example.com/webhooks/skovik. Skovik won't deliver over plain HTTP. - Choose the events to subscribe to.
- Copy the endpoint's secret,
whsec_followed by a base64 random value, intoSKOVIK_WEBHOOK_SECRET. - Trigger a test from the settings UI and check for a
misc.pingdelivery.
To receive the ping on your own machine, register a Hookdeck CLI URL:
npx hookdeck-cli listen 3000 skovik --path /webhooks/skovik
With Hookdeck. Use a Skovik source's URL as the endpoint and give the source the endpoint's whsec_ secret. Hookdeck answers each POST with 200 and stores signature failures as failed verification rather than forwarding them. It refuses methods other than POST. If you regenerate the secret in Skovik, update the source straight away.
Securing Skovik webhooks
Skovik follows the Standard Webhooks scheme and points to its libraries rather than printing the steps: HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of the secret after whsec_, sent as v1,<base64>. Skovik notes the signature covers the raw, unparsed body. The equivalent function:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Since deliveries only link to the resource, a handler verifies, deduplicates on the event ID and queues a fetch:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
const app = express();
app.post('/webhooks/skovik', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.SKOVIK_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const payload = parseJsonObject(req.body);
if (!payload) return res.sendStatus(400); // signed, but not a JSON object
const { type, data } = payload;
if (typeof type !== 'string') return res.sendStatus(400);
if (type === 'misc.ping') return res.sendStatus(200);
if (!data || typeof data !== 'object' || !data.id) return res.sendStatus(400);
const related = data.relationships?.related_object;
try {
if (related) {
if (typeof related.links?.related !== 'string') return res.sendStatus(400);
// data.id is the event ID: the same on every endpoint and retry.
await jobs.enqueueOnce(data.id, { type, path: related.links.related });
}
} catch {
return res.sendStatus(503); // not processed, so don't acknowledge
}
return res.sendStatus(200);
});
jobs.enqueueOnce is your own queue, which skips an event ID it has already seen. The worker calls Skovik's API at path and syncs the result; for export_orders.completed that's where the export file URL comes from.
Skovik re-signs each of its attempts with a new webhook-timestamp and leaves tolerance to the Standard Webhooks libraries, which use five minutes. Hookdeck, by contrast, applies no tolerance and replays Skovik's original timestamp when it retries or you replay an event, so services behind Hookdeck need a check without a time limit, like the one above.
Make Skovik webhooks production-ready. Hookdeck Event Gateway stores every Skovik notification and can throttle delivery, so the follow-up API calls don't arrive in a burst.
Skovik webhook limitations and pain points
Every event needs an API call
The Problem: A batch of changes turns into a burst of API requests from your workers.
Why It Happens: Deliveries link to the resource instead of embedding it.
Workarounds:
- Process events through a queue with a concurrency limit.
- Collapse several updates for the same resource into one fetch.
How Hookdeck Can Help: Hookdeck's delivery rate setting spreads events out before they reach your workers.
Regeneration breaks verification at once
The Problem: Every delivery fails verification right after someone regenerates the secret.
Why It Happens: Regenerating invalidates the old secret immediately; there is no overlap period.
Workarounds:
- Update your environment variable and Skovik in the same change, and expect failures in between. Return a
5xxrather than a401during the switch-over if you want Skovik's retries to pick them up later.
How Hookdeck Can Help: Updating the secret on one Hookdeck source is quicker than redeploying every receiver, and failed requests stay in Hookdeck for inspection.
Two IDs that mean different things
The Problem: Deduplication keyed on webhook-id processes the same event twice when it's sent to two endpoints.
Why It Happens: webhook-id (and the body id) identifies one delivery to one endpoint. data.id identifies the event across endpoints.
Workarounds:
- Deduplicate business logic on
data.id.
How Hookdeck Can Help: Deduplication can use a body field such as data.id.
Retry details are vague
The Problem: You can't predict when a failed delivery will next be attempted, or how many times.
Why It Happens: Skovik describes exponential backoff with jitter over about three days, but publishes no attempt count or schedule.
Workarounds:
- Use
GET /v1/webhook_and the resource APIs to reconcile after an outage.events/{id}
How Hookdeck Can Help: Hookdeck's own retries are configurable and visible per event.
Best practices
Fetch, don't trust
Always read the resource from links.related before acting. For users.updated and branches.updated, the event doesn't say what changed.
Handle misc.ping
Return 200 for the test event; it has no related resource.
Use the event ID
Record data.id with the work it triggered. Our guide to webhook idempotency covers durable storage.
Keep secrets per endpoint
Each endpoint has its own secret. Verify each request with the secret of the endpoint it was sent to.
Conclusion
Skovik's webhooks are small signals about users, branches and export orders, signed per Standard Webhooks. Verify each one, deduplicate on data.id, fetch the linked resource before acting, and coordinate secret regeneration carefully because the old secret stops working at once.
Hookdeck Event Gateway adds rate control for those follow-up API calls and a full record of every notification. Sign up and send Skovik's test ping to a Hookdeck source.