Agent skill

Pinata Webhooks Skill

Receive and verify Pinata webhooks (IPFS file storage and pinning, delivered via Svix with Standard Webhooks signatures). Use when setting up Pinata webhook handlers, debugging webhook-signature verification, or handling events like ipfs.public.file_added, ipfs.private.file_deleted, and ipfs.public.pin_by_cid_succeeded.

Install this skill

npx skills add hookdeck/webhook-skills --skill pinata-webhooks


Pinata delivers account webhooks (private and public IPFS file events, pin-by-CID and CAR-import events) through Svix, which implements Standard Webhooks. Every delivery is an HTTP POST with a JSON body and three signature headers: webhook-id, webhook-timestamp, and webhook-signature. The body is {event_type, timestamp, workspace_id, data}. The file sits under data.object (data.new_file for ipfs.public.pin_by_cid_succeeded). There is no event id in the body; the webhook-id header identifies the message. The two CAR-import events name the field event instead of event_type, so read event_type ?? event.

When to Use This Skill

  • How do I receive Pinata webhooks?
  • How do I verify Pinata webhook signatures (webhook-signature, whsec_ secret)?
  • How do I handle ipfs.public.file_added or ipfs.private.file_deleted events?
  • How do I react to ipfs.public.pin_by_cid_succeeded or ipfs.public.pin_by_cid_failed?
  • Why is my Pinata webhook signature verification failing, or rejecting Hookdeck retries?

Verification (core)

Signed content is {webhook-id}.{webhook-timestamp}.{raw body}. Svix signs it with HMAC-SHA256 using the base64-decoded secret (the part after whsec_), base64-encodes the result, and sends one or more space-separated v1,<signature> entries. Use the official svix library, which compares in constant time. Read the webhook-* headers, and when webhook-id is absent fall back to the svix-* headers (svix-id, svix-timestamp, svix-signature), as Hookdeck does.

import { Webhook } from 'svix'; // svix 2.x is ESM-only

const prefix = req.headers['webhook-id'] ? 'webhook' : 'svix';
const id = req.headers[`${prefix}-id`];
const timestamp = req.headers[`${prefix}-timestamp`];
const signature = req.headers[`${prefix}-signature`];
// rawBody: the raw Buffer/string, never re-serialized JSON
new Webhook(process.env.PINATA_WEBHOOK_SECRET).verify(rawBody, {
  'svix-id': id, 'svix-timestamp': timestamp, 'svix-signature': signature,
  'webhook-id': id, 'webhook-timestamp': timestamp, 'webhook-signature': signature,
}); // throws on a bad signature or a timestamp outside the window
const event = JSON.parse(rawBody.toString('utf8')); // parse only after verify passes
from svix.webhooks import Webhook, WebhookVerificationError

wh = Webhook(os.environ["PINATA_WEBHOOK_SECRET"])  # "whsec_..."
wh.verify(raw_body, {"svix-id": id, "svix-timestamp": ts, "svix-signature": sig,
                     "webhook-id": id, "webhook-timestamp": ts, "webhook-signature": sig})

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

The secret is on the webhook dashboard's right sidebar in the Pinata App (click the reveal icon) and starts with whsec_. Each endpoint has its own. Keep the full whsec_... value in PINATA_WEBHOOK_SECRET. Handlers fail closed: with the variable unset they return 500 instead of accepting anything. A missing header or a bad signature returns 400. Express must read the body with express.raw({ type: '*/*' }) so any content type works and the exact bytes are verified.

Hookdeck retries and replays hit the 5-minute window

The svix and standardwebhooks libraries always enforce a fixed 5-minute timestamp window, and it can't be configured. Hookdeck retries and replays resend the original webhook-timestamp, so the library rejects any retry or replay that happens more than 5 minutes after the original delivery. If you verify behind Hookdeck (or replay old events), use the manual verifier in references/verification.md, where the window can be dropped or widened. Deduplicate on webhook-id instead of relying on the timestamp.

Common Event Types

11 events are documented (the list may not be exhaustive). The name is event_type in the JSON body (event for the CAR-import events). The examples handle all of these:

EventTriggered When
ipfs.private.file_addedA file is added to private IPFS
ipfs.private.file_updatedA private file's metadata is updated
ipfs.private.file_deletedA file is deleted from private IPFS
ipfs.public.file_addedA file is added to public IPFS
ipfs.public.file_updatedA public file's metadata is updated
ipfs.public.file_deletedA file is deleted from public IPFS
ipfs.public.car_import_succeededA CAR file imported successfully after upload
ipfs.public.car_import_failedA CAR file failed to import after upload
ipfs.public.pin_by_cid_addedA pin by CID task is queued
ipfs.public.pin_by_cid_succeededPinata pinned the content from the public IPFS network
ipfs.public.pin_by_cid_failedPinata couldn't retrieve the content for a pin by CID task

See references/overview.md for payload shapes per event.

Environment Variables

# Signing secret of the webhook (Svix format, starts with whsec_)
PINATA_WEBHOOK_SECRET=whsec_xxxxx

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 pinata --path /webhooks/pinata

In the Pinata App, add the tunnel URL on the Webhooks tab, then trigger an event (for example, upload a file) to see it arrive.

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 13, 2026

View on GitHub →