Guide to Pinata Webhooks: Features and Best Practices
Pinata stores and pins files on IPFS, on both the public network and Pinata's private IPFS. Its webhooks report what happens to those files through the Pinata API: uploads, metadata edits, deletions, CAR imports and pin-by-CID jobs that fetch content from the public IPFS network. Pinata delivers them through Svix, which supplies the endpoint portal, delivery logs, rate limiting and the event catalogue.
What follows is a tour of Pinata's 11 file and pin events, two quirks in its catalogue that can break event routing, and how to check the Svix signature.
What are Pinata webhooks?
Pinata webhooks are configured per account in the Pinata App, as described on the webhooks page. Each endpoint has a URL, a list of subscribed events and its own signing secret. Pinata documents very little itself and points to Svix for the rest, so most delivery details, such as the POST method and the signature scheme, come from Svix's documentation.
The event catalogue at event-types.pinata.cloud is the only place that shows payloads. Its examples put the event name in a top-level event_type field, next to timestamp, workspace_id and data.
Pinata webhook features
| Feature | Details |
|---|---|
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | webhook-signature |
| Body | JSON |
| Event routing | Body field event_type |
| Delivery platform | Svix |
| Configuration | Pinata App, Webhooks tab |
| Secret | whsec_, one per endpoint, on the endpoint dashboard's right sidebar |
| Event ID | webhook-id header (msg_...); none in the body |
| Rate limiting | Optional, per endpoint |
| Timeout, retries, ordering | Not stated by Pinata |
| Source IPs | Not published |
Events and payloads
| Event | Triggered When | Common Use Cases |
|---|---|---|
ipfs.private.file_added | A file is added to private IPFS | Index uploads, start processing |
ipfs.private.file_ | A private file's metadata is updated | Sync name or keyvalues |
ipfs.private.file_ | A file is deleted from private IPFS | Remove from your database |
ipfs.public.file_added | A file is added to public IPFS | Record the CID, publish links |
ipfs.public.file_updated | A public file's metadata is updated | Sync name or keyvalues |
ipfs.public.file_deleted | A file is deleted from public IPFS | Remove from your database |
ipfs.public.car_ | A CAR file imported successfully after upload | Mark an upload complete |
ipfs.public.car_ | A CAR file failed to import after upload | Surface the error, retry |
ipfs.public.pin_ | A pin by CID task is queued | Track a pending pin |
ipfs.public.pin_ | Pinata pinned the content from the public IPFS network | Mark the pin done |
ipfs.public.pin_ | Pinata couldn't retrieve the content for a pin by CID task | Alert, retry |
The catalogue doesn't claim to be complete, so acknowledge unknown types with 200.
This ipfs.private.file_added example comes from the webhook samples library:
{
"data": {
"object": {
"cid": "bafybeifhh5a2o4ilxviboof4c75kkv62hkljpdq3fo3uowkhdrgjr67kbm",
"created_at": "2025-01-07T20:13:39.536Z",
"group_id": "11111111-1111-1111-1111-111111111111",
"id": "11111111-1111-1111-1111-111111111111",
"keyvalues": {
"examplekey": "example value",
"examplekey2": "example value 2"
},
"mime_type": "image/png",
"name": "001.png",
"number_of_files": 42,
"size": 42
}
},
"event_type": "ipfs.private.file_added",
"timestamp": "2025-01-08T16:01:57.025041Z",
"workspace_id": "11111111-1111-1111-1111-111111111111"
}
The other types vary this shape:
*.file_updatedaddsdata.previous_attributeswith the values that changed.ipfs.public.pin_andby_ cid_ added ipfs.public.pin_put the pin request inby_ cid_ failed data.object(cid,date_queued,host_nodes,status).ipfs.public.pin_adds the new file asby_ cid_ succeeded data.new_file, alongside the pin request indata.object.- The two CAR-import examples are generated from schemas and use a field called
event, notevent_type.car_import_failedcarries onlydata.erroranddata.file_id. - The
pin_by_cid_succeededexample printsipfs.public.pin_as itsby_ cid_ created event_type, which isn't a catalogue name.
No real delivery of the last two cases has been published, so the safest routing reads event_type ?? event and treats pin_by_cid_created as pin_by_cid_succeeded.
Setting up Pinata webhooks
- In the Pinata App, open the Webhooks tab and click Add Endpoint.
- Enter your endpoint URL, for example
https://example.com/webhooks/pinata. If you don't have one yet, the Svix Play link under the field creates a temporary test endpoint. - Optionally add a description, pick events from the catalogue and set advanced options such as rate limiting.
- Click Create. The endpoint's dashboard opens, showing incoming logs and fired events.
- Reveal the secret in the right sidebar and store it,
whsec_prefix included, asPINATA_WEBHOOK_SECRET.
To see real payloads during development, run the Hookdeck CLI, add its URL as an endpoint, then upload and delete a test file:
npx hookdeck-cli listen 3000 pinata --path /webhooks/pinata
With Hookdeck. Make a Pinata source, add its URL as the endpoint in the Pinata App and paste the endpoint's whsec_ secret into the source. It reads the webhook-* headers and falls back to svix-* names if they appear. Every POST gets a 200, and requests that fail the signature check are marked as failed verification and not forwarded. Other methods are refused.
Securing Pinata webhooks
Pinata's documentation only shows Svix's library call (new Webhook(secret).verify(payload, headers)) and links to Svix for the details. Per Svix, the signed content is {webhook-id}.{webhook-timestamp}.{raw body}, the key is the base64-decoded secret after whsec_, and webhook-signature holds space-separated v1,<base64> values. Without a library:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
A handler that copes with both event-name fields and the mismatched pin name:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
const app = express();
app.post('/webhooks/pinata', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.PINATA_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
let event_type = event.event_type ?? event.event;
if (event_type === 'ipfs.public.pin_by_cid_created') event_type = 'ipfs.public.pin_by_cid_succeeded';
if (typeof event_type !== 'string') return res.sendStatus(400);
const object = event.data?.object;
const message_id = req.headers['webhook-id'] ?? req.headers['svix-id'];
const handled = event_type.endsWith('.file_added') || event_type.endsWith('.file_deleted') || event_type === 'ipfs.public.pin_by_cid_succeeded';
if (handled && (!object || typeof object !== 'object')) return res.sendStatus(400);
try {
if (event_type === 'ipfs.private.file_added' || event_type === 'ipfs.public.file_added') {
await files.index(message_id, object.cid, object);
} else if (event_type === 'ipfs.private.file_deleted' || event_type === 'ipfs.public.file_deleted') {
await files.remove(message_id, object.id);
} else if (event_type === 'ipfs.public.pin_by_cid_succeeded') {
await pins.complete(message_id, object.cid, event.data.new_file);
}
} catch {
return res.sendStatus(503); // not processed, so don't acknowledge
}
return res.sendStatus(200);
});
files and pins represent your own storage. Because the body has no event ID, message_id from the header is the deduplication key.
Svix's library, which Pinata's sample uses, refuses messages whose timestamp is more than five minutes off. Hookdeck sets no such limit and keeps the original webhook-timestamp when it retries or replays a request, so a delayed message verified with the library would fail. Use the function above, or turn off the library's timestamp check, behind Hookdeck.
Make Pinata webhooks production-ready. Hookdeck Event Gateway records every Pinata delivery, so you can see exactly which event name a CAR import or pin actually carried.
Pinata webhook limitations and pain points
The catalogue's event names don't line up
The Problem: A handler that matches catalogue names misses some events.
Why It Happens: The CAR-import examples name the field event, and the pin_by_cid_succeeded example uses a different type string. Pinata hasn't said which version a real delivery uses.
Workarounds:
- Read
event_type ?? event, mappin_by_cid_createdtopin_by_cid_succeeded, and log anything unmatched.
How Hookdeck Can Help: Hookdeck keeps the raw body of every delivery, so the first real CAR import or pin settles the question. Filters can match either field.
Delivery policy is undocumented
The Problem: You can't tell how long Pinata will retry, how fast you must answer, or whether an endpoint can be disabled.
Why It Happens: Pinata states no timeout, retry schedule, ordering or disabling policy. Svix's defaults probably apply, but Pinata doesn't confirm them.
Workarounds:
- Answer quickly and process in the background.
- Check the endpoint dashboard's logs after an outage to find failed messages.
How Hookdeck Can Help: With Hookdeck in front, the retry behaviour that matters is Hookdeck's, which you configure and can see.
No event ID in the body
The Problem: Duplicates are hard to spot from the payload alone.
Why It Happens: Bodies carry event_type, timestamp, workspace_id and data, but no unique ID. File IDs and CIDs repeat across events about the same file.
Workarounds:
- Store the
webhook-idheader value and skip it on a repeat.
How Hookdeck Can Help: Deduplication can key on the webhook-id header.
Svix library and secret handling
The Problem: Verification with the Svix library fails on a valid request.
Why It Happens: The library base64-decodes the secret after stripping whsec_, and enforces a five-minute timestamp window.
Workarounds:
- Pass the full
whsec_value, and drop the timestamp check for anything replayed or delayed.
How Hookdeck Can Help: The source verifies with no age limit, so retried and replayed Pinata messages still pass.
Best practices
Subscribe per use case
Private and public files have separate events. Subscribe only to the namespace you use, and to the pin-by-CID events only if you pin existing content.
Use cid and id deliberately
data.object.id identifies a file in Pinata; cid identifies its content and can be shared by several files. Key your records on id and look content up by cid.
Handle file_updated with previous_attributes
Compare data.previous_attributes with the new values to update only what changed, such as name or keyvalues. For storing processed message IDs, follow our idempotency guide.
Keep the raw body
Verify against the exact bytes, using express.raw() here or await request.text() in a Next.js route, and parse only afterwards.
Conclusion
Pinata's Svix-delivered webhooks are a straightforward way to react to file uploads, deletions and pin jobs on IPFS. The parts to handle with care are the catalogue's inconsistent event names, the missing event ID in the body, and a delivery policy Pinata doesn't spell out.
Hookdeck Event Gateway records each delivery and gives you a retry policy you control. Get started free by adding a Pinata source as an endpoint.