Skip to content

Receive webhooks

A quick start for the server that receives Pixonaut’s events: register an endpoint, verify each request, and acknowledge it. The rules behind each step, the retry schedule and every event’s payload are under Webhooks.

1. Register an endpoint

In the studio, under Webhooks, choose Add endpoint and give it the URL your server receives on. Leave Event types unchecked to receive every event, or check the ones you handle. Copy the endpoint’s signing secret when it is shown: it is shown once.

2. Verify each request

Read the raw body as it arrived, before parsing it, and check its signature with the endpoint’s secret. In Node:

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;
const MS_PER_SECOND = 1000;

// `headers` with lowercase names, `rawBody` the bytes as they arrived.
export function verifyWebhook(secret, headers, rawBody, now = Date.now()) {
  const timestamp = Number(headers["x-pixonaut-timestamp"]);
  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(now / MS_PER_SECOND - timestamp) > TOLERANCE_SECONDS) {
    return false;
  }
  const expected = crypto
    .createHmac("sha256", secret)
    .update(String(timestamp))
    .update(".")
    .update(rawBody)
    .digest();
  // The header may carry other schemes beside this one, comma-separated.
  return String(headers["x-pixonaut-signature"] ?? "")
    .split(",")
    .some((entry) => {
      const [scheme, hex = ""] = entry.trim().split("=");
      if (scheme !== "v1" || !/^[0-9a-f]+$/.test(hex)) return false;
      const given = Buffer.from(hex, "hex");
      return (
        given.length === expected.length &&
        crypto.timingSafeEqual(given, expected)
      );
    });
}

Refuse a request that does not verify, without acting on its body.

3. Store, then acknowledge

A delivery answered with a 2xx is never sent again, so answer one only once the event is safe: either handle it before answering, or store or enqueue the verified event durably and answer 200 then, handling it from there. A slow or failed answer is retried, and a retry carries the same event id, so skip an id whose work has succeeded. Count an id as handled only once its work has, so that work which failed is done again when the event is retried or picked up from your queue:

{
  "id": "1790000090000-3b4c5d6e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
  "type": "print.completed",
  "createdAt": 1790000090000,
  "payload": { "printId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f" }
}

4. Follow what a store sells

A template a store sells can be paused by a member, after which customers cannot design on it. Each pause and each resume is one template.live_changed, naming the template by baseId: live: false is a pause, live: true a resume. Hide the designer for a paused template and show it again once it is resumed. A delivery can be missed, and a store connected after a pause heard nothing of it, so the event is a prompt rather than the record: status on GET /v1/templates/{id} answers live or paused for any version of the template, and is the state to trust.

A store that keeps the address of a link it handed out, such as a designer link on a product page, follows share_link.revoked and share_link.regenerated: the address it kept no longer opens. Neither carries the new address; a designer link’s is read again from the share-link list, as its url. A connected store’s endpoint hears of the links its own keys handed out; an endpoint no store owns hears of every one.

Use Send test request in the endpoint’s delivery log to check that your server is reachable and verifies; the test request is signed the same way.

Questions? Write to [email protected].

Status · Terms of service · Privacy policy · Subprocessors