Home / Docs / Webhooks

Webhooks

Webhooks push pin outcomes to your endpoint instead of making you poll. Register a URL, verify the signature on each delivery, and react to the event.

Prerequisites

  • An API key in the X-API-Key header. See Authentication.
  • A publicly reachable HTTPS endpoint that returns a 2xx quickly.
  • A secret string, 16 to 255 characters, used to sign deliveries. Store it where your endpoint can read it.

Events

PinBridge emits exactly two event types:

Event When Payload
pin.published A pin publishes successfully { "pin_id", "pinterest_pin_id", "title", "published_at" }
pin.failed A pin fails permanently { "pin_id", "error_code", "error_message" }

Example bodies:

{
  "pin_id": "8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93",
  "pinterest_pin_id": "1234567890123456789",
  "title": "My first PinBridge pin",
  "published_at": "2026-09-11T10:05:00Z"
}
{
  "pin_id": "8a3f6c12-4d5e-4b7a-9c01-2e6f8d4b7a93",
  "error_code": "board_access_denied",
  "error_message": "The connected account cannot write to this board."
}

These are the only events that fire. You can technically subscribe to other event names, but nothing else is ever delivered. The JSON body carries no envelope: there is no top-level event, id, or timestamp field. The event type travels in a header instead.

Delivery headers

Every delivery carries:

Header Value
Content-Type application/json
X-PinBridge-Event The event type, e.g. pin.published
X-PinBridge-Signature HMAC-SHA256 of the raw body, hex-encoded (see below)
X-PinBridge-Delivery-ID A unique id for this delivery attempt group

Verify the signature

X-PinBridge-Signature is HMAC-SHA256(secret, raw_request_body), hex-encoded.

Two things to get right:

  • Sign the raw body bytes, exactly as received. Do not re-serialize the parsed JSON first, or whitespace and key order differences will break the match.
  • Only the body is signed. There is no timestamp in the signed string and no timestamp header. This is not Stripe’s t=...,v1=... scheme, so don’t try to parse the signature that way.

Compute the same HMAC over the raw body with your secret, then compare it to the header using a constant-time comparison. Reject the request if they differ.

Python

The snippet reads the raw request body, computes hmac.new(secret, body, sha256).hexdigest(), and compares it to X-PinBridge-Signature with hmac.compare_digest. Wire it in before you parse or trust the JSON.

"""Verify a PinBridge webhook signature (Python).

PinBridge signs the raw JSON request body with HMAC-SHA256 (hex) using your webhook
secret, and sends it in the `X-PinBridge-Signature` header. Verify against the RAW body
bytes, before parsing JSON. Matches api/app/core/security.py: sign_webhook_payload.
"""
import hashlib
import hmac


def verify_signature(secret: str, raw_body: bytes, signature: str) -> bool:
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")


# Example (Flask):
#   @app.post("/pinbridge-webhook")
#   def hook():
#       raw = request.get_data()  # raw bytes, not request.json
#       if not verify_signature(SECRET, raw, request.headers.get("X-PinBridge-Signature", "")):
#           abort(401)
#       event = request.headers["X-PinBridge-Event"]      # pin.published | pin.failed
#       delivery_id = request.headers["X-PinBridge-Delivery-ID"]  # for idempotent handling
#       data = json.loads(raw)
#       ...

Node

The snippet does the same in Node: crypto.createHmac("sha256", secret).update(rawBody).digest("hex"), compared to the header with crypto.timingSafeEqual. Make sure your framework gives you the raw body (for example Express’s express.raw()), not an already-parsed object.

// Verify a PinBridge webhook signature (Node).
// HMAC-SHA256 (hex) over the RAW request body, compared to the X-PinBridge-Signature header.
const crypto = require("crypto");

function verifySignature(secret, rawBody, signature) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Example (Express): use express.raw({type:"application/json"}) so req.body is a Buffer.
//   app.post("/pinbridge-webhook", express.raw({type:"application/json"}), (req, res) => {
//     if (!verifySignature(SECRET, req.body, req.get("X-PinBridge-Signature"))) return res.sendStatus(401);
//     const event = req.get("X-PinBridge-Event");                 // pin.published | pin.failed
//     const deliveryId = req.get("X-PinBridge-Delivery-ID");      // idempotency key
//     const data = JSON.parse(req.body.toString());
//     res.sendStatus(200);
//   });

module.exports = { verifySignature };

Register a webhook

POST /v1/webhooks. The secret is required (16 to 255 chars); events defaults to both event types.

curl

curl -X POST https://api.pinbridge.io/v1/webhooks \
  -H "X-API-Key: $PINBRIDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/pinbridge",
    "secret": "a-strong-secret-at-least-16-chars",
    "events": ["pin.published", "pin.failed"],
    "is_enabled": true
  }'

Response (201, trimmed):

{
  "id": "d2f6b0e4-8a3c-4d7f-b1e9-2a6c4f8d0b53",
  "url": "https://example.com/webhooks/pinbridge",
  "events": ["pin.published", "pin.failed"],
  "is_enabled": true
}

The secret is never returned in responses. Keep your own copy.

Python SDK

from pinbridge_sdk import PinbridgeClient
from pinbridge_sdk.models import WebhookCreate

with PinbridgeClient(api_key="$PINBRIDGE_API_KEY") as client:
    webhook = client.webhooks.create(
        WebhookCreate(
            url="https://example.com/webhooks/pinbridge",
            secret="a-strong-secret-at-least-16-chars",
        )
    )
    print(webhook.id, webhook.events)

Manage webhooks with client.webhooks.list(), get(id), update(id, WebhookUpdate), and delete(id).

n8n

There are two different nodes here:

  • To receive events, use n8n’s generic Webhook node as the listener, then point PinBridge at that node’s URL. The PinBridge community node ships no trigger node.
  • To register the URL, use the PinBridge node with Resource: Webhook, Operation: Create (default events pin.published,pin.failed).

Retries and delivery

  • Success is any HTTP 2xx. Anything else (non-2xx, timeout, connection error) is a failure and is retried.
  • Up to 5 attempts per delivery.
  • Exponential backoff in minutes, base 2, so retries land roughly 2, 4, 8, and 16 minutes after the failed attempts.
  • Per-attempt timeout is 30 seconds. Return fast, then do slow work asynchronously.

After 5 attempts the delivery is marked permanently failed.

Write idempotent consumers

Because deliveries can be retried, your endpoint may receive the same event more than once. Make handling idempotent: dedupe on the X-PinBridge-Delivery-ID header, or on pin_id combined with the event type. Record what you’ve processed and no-op on repeats.

Verify it worked

  • The create call returns 201 with the webhook id and is_enabled: true.
  • Publish a pin and watch for a pin.published (or pin.failed) POST to your endpoint.
  • Confirm your endpoint returns a 2xx and that your signature check passes.

Common errors

Status Cause
422 secret is missing or shorter than 16 characters, or url is not a valid URL.
404 Webhook not found — wrong webhook id on get / update / delete.
Deliveries retried and eventually dropped: your endpoint returned non-2xx or timed out past 30s.

Next steps

Last updated September 13, 2026Was this page helpful? Tell us →