Skip to content

Most Pinterest integrations find out whether a pin published the same way: a loop that calls GET /pins/{id} every minute until the status changes. It works in the demo. In production it becomes a rate-limit problem, then a latency problem, then a cost problem. A webhook fixes all three, but only if the receiver is built to survive retries.

This post is about the receiving side. What PinBridge sends, how to prove a request came from us, what to do when the same event arrives twice, and the one place where polling still belongs.

Why polling is the wrong shape for publishing

Publishing a pin is asynchronous. A pin you queue now might go out in 40 seconds, or at 09:00 tomorrow because that is when you scheduled it. A poller has to pick an interval before it knows which case it is in.

Pick a short interval and you burn quota. 200 pins in a day’s queue, polled every 60 seconds, is 288,000 requests to learn 200 facts. That traffic competes with the publishes themselves for the same rate limits. Pick a long interval and your dashboard lags. A pin that failed at 09:00 shows “pending” until the 09:15 sweep, and by then the person who could have fixed the board permission is doing something else.

Neither interval is right. PinBridge already knows the status the moment it changes. The fix is to have it tell you.

What a PinBridge webhook sends

PinBridge emits two events, pin.published and pin.failed. Each is a single POST to your URL with a JSON body and three headers.

POST /hooks/pinbridge HTTP/1.1
Content-Type: application/json
X-PinBridge-Event: pin.published
X-PinBridge-Delivery-ID: 6f1c2f0e-9a3b-4d2e-8c1a-2b7e5d9f0a11
X-PinBridge-Signature: 3f0a...c9e2

{"pin_id":"...","pinterest_pin_id":"1234567890","title":"Spring kitchen ideas","published_at":"2026-09-23T09:00:04.118Z"}

The failure event carries the error instead of the Pinterest id:

{"pin_id":"...","error_code":"board_access_denied","error_message":"..."}

Three details matter when you write the consumer. The body is the event data with no envelope around it; the event name and the delivery id travel in headers, so read the headers. The body is compact JSON with no spaces after separators, and the bytes you verify have to be the bytes we sent, so do not re-serialize before checking the signature. And the delivery id belongs to the delivery, not the attempt: if we retry, the id is the same. That is your deduplication key.

Register an endpoint

One call. You generate the secret; PinBridge stores it and signs every delivery with it. It has to be at least 16 characters, and 32 random bytes as hex is a sensible default.

curl -X POST https://api.pinbridge.io/v1/webhooks \
  -H "Authorization: Bearer $PINBRIDGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/pinbridge",
    "secret": "'"$(openssl rand -hex 32)"'",
    "events": ["pin.published", "pin.failed"]
  }'

Or from the Python SDK:

import os
from pinbridge_sdk import PinbridgeClient
from pinbridge_sdk.models import WebhookCreate

client = PinbridgeClient(api_key=os.environ["PINBRIDGE_API_KEY"])
hook = client.webhooks.create(
    WebhookCreate(
        url="https://example.com/hooks/pinbridge",
        secret=os.environ["PINBRIDGE_WEBHOOK_SECRET"],
        events=["pin.published", "pin.failed"],
    )
)

The API never returns the secret after creation, so keep it in your own secret store. Rotate it with PATCH /v1/webhooks/{id} instead of creating a second endpoint.

Verify the signature before you parse anything

X-PinBridge-Signature is a lowercase hex HMAC-SHA256 of the raw request body, keyed with your secret. The check is four lines. Three of them are easy to get wrong.

import hmac, hashlib, json, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
SECRET = os.environ["PINBRIDGE_WEBHOOK_SECRET"].encode()

@app.post("/hooks/pinbridge")
async def pinbridge_hook(request: Request):
    body = await request.body()                      # raw bytes, not request.json()
    given = request.headers.get("X-PinBridge-Signature", "")
    expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, given):     # constant-time compare
        raise HTTPException(status_code=401)

    delivery_id = request.headers["X-PinBridge-Delivery-ID"]
    event = request.headers["X-PinBridge-Event"]
    await enqueue_once(delivery_id, event, json.loads(body))
    return {"ok": True}

The mistakes, in the order we see them:

  1. Verifying a re-serialized body. If your framework parses JSON before your handler runs and you sign json.dumps(parsed), key order or whitespace will differ and the check fails some of the time. Read the raw bytes. In Express that means express.raw({ type: "application/json" }) on this route, registered before any json() middleware.
  2. Comparing with ==. A plain string compare leaks timing. Use hmac.compare_digest in Python or crypto.timingSafeEqual in Node. Node’s version throws when the buffers differ in length, so check expected.length === given.length first and return a 401 on mismatch rather than crashing into a 500.
  3. Doing the real work inside the request. PinBridge waits up to 30 seconds for a response. If your handler updates three tables and calls Slack, a slow Slack becomes a webhook retry, and the retry becomes a duplicate. Verify, deduplicate, enqueue, return 2xx. Do the work off the request path.

There is no timestamp in the signed content, so a captured delivery could in principle be replayed. The delivery id closes that gap in practice: once you have processed an id, later copies are no-ops regardless of who sent them.

Build the consumer to be idempotent

PinBridge treats any non-2xx response, or any connection failure, as a miss and retries. Up to 5 attempts in total. Backoff is 2^n minutes after attempt n (2, 4, 8, 16), and the retry sweep runs every 5 minutes, so real gaps round up to the next tick. After the last attempt the workspace owner gets an email naming the endpoint and the failure. If a retry succeeds after earlier misses, they get a recovery email instead.

The consequence for you: the same delivery id can arrive more than once, and out of order relative to other deliveries. The receiver has to be safe under both.

async def enqueue_once(delivery_id: str, event: str, payload: dict) -> None:
    inserted = await db.execute(
        "INSERT INTO pinbridge_deliveries (id, event, payload) "
        "VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
        delivery_id, event, payload,
    )
    if inserted.rowcount == 0:
        return                       # already seen; a PinBridge retry, ack it
    await queue.publish("pinbridge.events", {"id": delivery_id})

A unique index on the delivery id plus ON CONFLICT DO NOTHING is the whole trick. The worker that drains the queue then updates your pin record by pin_id, which is safe to run twice. Do not key anything on pinterest_pin_id; it only exists on pin.published.

One thing that surprises people: pin.failed is final. PinBridge retries transient Pinterest errors on its own, with backoff, before it sends anything. When pin.failed arrives, either the error was one a retry cannot fix or the retries ran out. So route on error_code. board_access_denied needs a human. An expired token needs the reconnect flow. Neither needs your code to try again.

Where polling still belongs

Webhooks tell you about events. They cannot tell you about the events you missed. If your endpoint was down for 45 minutes, PinBridge will have exhausted its retries on some deliveries and stopped. You will have the email, but your database will still say “pending”.

So keep one poller and give it a different job: reconciliation. Once an hour, list the pins your system still thinks are pending and older than, say, 30 minutes, and ask PinBridge for their current status with GET /v1/pins/{id}. That is a handful of calls per hour instead of thousands, and it catches every gap the webhook path can leave. An outage on your side. A rotated secret you forgot to deploy. A delivery that failed 5 times while you were asleep.

Webhooks carry the fast path. The hourly sweep carries the truth. With both in place, the loop that hit GET /pins/{id} every minute goes away and nothing gets lost.

Try it against sandbox first

Sandbox workspaces emit the same events with the same headers, so you can build the receiver, break it on purpose, and watch the retries land before a real pin is involved. Start in sandbox. Move to production when the workflow is proven.

The endpoint reference and the n8n trigger node are in the webhook docs. If you would rather see what your pins are doing right now, without a poller, start free.

Leave a Reply

Your email address will not be published. Required fields are marked *

Skip the token plumbing

PinBridge is a hosted Pinterest publishing API. Connect an account once and we handle the refresh loop, the locking and the reconnect prompt.