Gareth Wilson Gareth Wilson

Guide to Skovik Webhooks: Features and Best Practices

Published


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_object 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

FeatureDetails
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature
BodyJSON
Event routingBody field type
Payload styleThin JSON:API notification linking to the resource
ConfigurationSettings UI, HTTPS only
Secretwhsec_ plus base64, one per endpoint
RegenerationOld secret stops working immediately
IDswebhook-id per delivery; data.id per event
RetriesExponential backoff with jitter over roughly three days
Event lookupGET /v1/webhook_events/{id}

Events and payloads

Skovik documents ten events:

EventTriggered WhenCommon Use Cases
users.createdA user is createdProvision the user in your system
users.updatedSome part of the user object changedRe-fetch the user and sync
users.archivedA user is archivedDeprovision the user
export_orders.createdAn export order is createdTrack export requests
export_orders.completedAn export order completed successfullyFetch the order to get the export file URL
export_orders.failedAn export order failedFetch the order and read error_message
branches.createdA branch is createdSync branches
branches.updatedA branch changedRe-fetch the branch and sync
branches.archivedA branch is archivedRemove or flag the branch
misc.pingA test is triggered from the settings UICheck 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"
        }
      }
    }
  }
}
FieldMeaning
idDelivery ID, equal to the webhook-id header
typeThe event name. data.attributes.name repeats it, and data.type is always webhook_events
timestampWhen this delivery attempt was signed and sent
data.idThe event ID, the same for every endpoint that receives the event
data.relationships.related_objectThe 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_events/{id} (with read access to webhook_events) returns an event exactly as Skovik sent it.

Setting up Skovik webhooks

  1. 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.
  2. Choose the events to subscribe to.
  3. Copy the endpoint's secret, whsec_ followed by a base64 random value, into SKOVIK_WEBHOOK_SECRET.
  4. Trigger a test from the settings UI and check for a misc.ping delivery.

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 5xx rather than a 401 during 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_events/{id} and the resource APIs to reconcile after an outage.

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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.