# Guide to Pinata Webhooks: Features and Best Practices

[Pinata](https://pinata.cloud) 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](https://docs.pinata.cloud/account-management/webhooks). 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](https://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_updated` | A private file's metadata is updated | Sync name or keyvalues |
| `ipfs.private.file_deleted` | 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_import_succeeded` | A CAR file imported successfully after upload | Mark an upload complete |
| `ipfs.public.car_import_failed` | A CAR file failed to import after upload | Surface the error, retry |
| `ipfs.public.pin_by_cid_added` | A pin by CID task is queued | Track a pending pin |
| `ipfs.public.pin_by_cid_succeeded` | Pinata pinned the content from the public IPFS network | Mark the pin done |
| `ipfs.public.pin_by_cid_failed` | 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/pinata/latest/ipfs.private.file_added.json):

```json
{
  "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_updated` adds `data.previous_attributes` with the values that changed.
* `ipfs.public.pin_by_cid_added` and `ipfs.public.pin_by_cid_failed` put the pin request in `data.object` (`cid`, `date_queued`, `host_nodes`, `status`).
* `ipfs.public.pin_by_cid_succeeded` adds the new file as `data.new_file`, alongside the pin request in `data.object`.
* The two CAR-import examples are generated from schemas and use a field called `event`, not `event_type`. `car_import_failed` carries only `data.error` and `data.file_id`.
* The `pin_by_cid_succeeded` example prints `ipfs.public.pin_by_cid_created` as its `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

1. In the Pinata App, open the **Webhooks** tab and click **Add Endpoint**.
2. 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.
3. Optionally add a description, pick events from the [catalogue](https://event-types.pinata.cloud/) and set advanced options such as rate limiting.
4. Click **Create**. The endpoint's dashboard opens, showing incoming logs and fired events.
5. Reveal the secret in the right sidebar and store it, `whsec_` prefix included, as `PINATA_WEBHOOK_SECRET`.

To see real payloads during development, run the [Hookdeck CLI](/docs/cli), add its URL as an endpoint, then upload and delete a test file:

```bash
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:

```javascript
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:

```javascript
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](/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`, map `pin_by_cid_created` to `pin_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](/docs/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](/docs/retries) 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-id` header value and skip it on a repeat.

**How Hookdeck Can Help:** [Deduplication](/docs/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](/webhooks/guides/implement-webhook-idempotency).

### 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](https://hookdeck.com) records each delivery and gives you a retry policy you control. [Get started free](https://dashboard.hookdeck.com/signup) by adding a Pinata source as an endpoint.