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-Keyheader. 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
201with the webhookidandis_enabled: true. - Publish a pin and watch for a
pin.published(orpin.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
- Publish pins — trigger
pin.published/pin.failed. - Schedule pins — scheduled pins fire the same events when they run.
- Rate limits and quotas — the failure
error_codevalues you’ll see inpin.failed. - Docs home — quickstart and the rest of the API.
