Receive webhooks and check signatures

Step-by-step guide
  • About 10 minutes
  • 7 steps
  • Growth and Agency
  • Checked 2 Nov 2026
0 of 7 steps done

Before you start

  • You are an owner or admin of the workspace.
  • An endpoint on your server that accepts POST requests with a JSON body, on a public https:// address using port 443 or 8443. Local and private addresses are blocked.
  • Your endpoint can read the raw request body before parsing it, so the signature check works.

Steps

  1. Step 1

    Open Notifications

    Under Settings in the left-hand menu, click Notifications and scroll to Slack, Discord and webhook.

    Where to find it in Skylane
    1. Settings
    2. Notifications
    3. Slack, Discord and webhook
  2. Step 2

    Add your webhook URL

    Paste your address into Your own webhook URL and click Add Your own webhook.

    Diagram: on the Notifications screen, press Add Your own webhook.
  3. Step 3

    Copy the signing secret

    Under Copy your webhook signing secret now, click Copy and save the secret in your server's secret settings. It is shown only once. Then click I have saved it.

  4. Step 4

    Choose the events

    Scroll up to the Alerts table. In the Your own webhook column, switch on each event you want, such as Post failed or Account needs reconnecting. These ticks save straight away.

  5. Step 5

    Check the signature on every message

    Each request carries a Skylane-Signature: t=<unix time>,v1=<hex> header. v1 is an HMAC-SHA256 of the timestamp, a full stop and the raw body, using your secret. Reject messages that do not match or are more than 5 minutes old.

    Verify in Node.jsjavascript
    import crypto from "node:crypto";
    
    // rawBody must be the exact bytes Skylane sent. Do not parse and re-serialise the JSON first.
    export function verifySkylane(rawBody, header, secret, toleranceSeconds = 300) {
      const fields = Object.fromEntries(String(header || "").split(",").map((part) => part.split("=", 2)));
      const timestamp = Number(fields.t);
      if (!timestamp || !fields.v1) return false;
      if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
      const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
      const a = Buffer.from(expected, "hex");
      const b = Buffer.from(fields.v1, "hex");
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    }
    
    // Express example: keep the raw body for this route.
    // app.post("/skylane-webhook", express.raw({ type: "application/json" }), (req, res) => {
    //   if (!verifySkylane(req.body, req.get("Skylane-Signature"), process.env.SKYLANE_WEBHOOK_SECRET)) return res.sendStatus(401);
    //   const event = JSON.parse(req.body);
    //   res.sendStatus(200); // reply first, then do the slow work
    // });
  6. Step 6

    Or check it in Python

    The same check in Python, matching Skylane's own reference code:

    Verify in Pythonpython
    import hashlib
    import hmac
    import time
    
    
    def verify_skylane(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
        """raw_body must be the exact bytes Skylane sent."""
        try:
            fields = dict(item.split("=", 1) for item in header.split(","))
            timestamp = int(fields["t"])
            supplied = fields["v1"]
        except (AttributeError, KeyError, ValueError):
            return False
        if abs(time.time() - timestamp) > tolerance:
            return False
        message = str(timestamp).encode() + b"." + raw_body
        expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, supplied)
  7. Step 7

    Reply quickly

    Reply with any 2xx status within 10 seconds, then do slow work afterwards. The Skylane-Event header names the event so you can route it before parsing.

Good to know

  • Event types: post_published, post_failed, account_needs_reconnect, token_expiring_7d, queue_low, approval_requested and approval_decided. Digests are email and in-app only.
  • data.title and data.message hold the same plain-English text the in-app alert shows. Visitor IP addresses are never included.
  • A webhook that fails 20 times in a row is paused. Fix your endpoint, then click Turn back on.
  • Settings, then Developer also has a Webhooks panel with Send test and a log of the last 20 deliveries. It signs messages the same way, with its own secret.
Example message bodyjson
{
  "id": "evt_EXAMPLE",
  "type": "post_failed",
  "createdAt": "2026-11-02T09:30:00Z",
  "workspaceId": "YOUR_WORKSPACE_ID",
  "data": {
    "postId": "YOUR_POST_ID",
    "platform": "instagram",
    "platformName": "Instagram",
    "accountName": "Your account name",
    "reason": "The plain-English reason the post failed.",
    "fixHint": "What to do about it.",
    "errorCode": "THE_PLATFORM_CODE",
    "link": "https://skylane.moneymasterhq.com/app/workspaces/YOUR_WORKSPACE_ID/posts/YOUR_POST_ID?retry=1",
    "title": "A post to Instagram could not be published",
    "message": "The plain-English reason the post failed. What to do about it. You can retry it from the post."
  }
}

Common problems

"Webhook URLs must start with https://"

Use an https address. Plain http, localhost, private IP addresses and ports other than 443 and 8443 are not accepted.

Every signature check fails

You are probably hashing parsed JSON. Hash the raw bytes exactly as received, and check you saved the secret for this webhook, not another one.

Checks fail only sometimes

Your server clock may be out. Sync it with NTP; the check allows 5 minutes either way.

I lost the signing secret

Click Remove next to Your own webhook, add it again and copy the new secret.

Still stuck? Contact support

Tell us which step you reached and what you see on screen. A screenshot helps.