> ## Documentation Index
> Fetch the complete documentation index at: https://developers.mypilotindex.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> The grant.revoked and ping events, signed in the Standard Webhooks scheme.

MyPilotIndex tells a partner about its grants by webhook. Two events exist: `grant.revoked`, when a grant ends, and `ping`, when you ask for a test.

MyPilotIndex sets your webhook URL (https only) and its signing secret when it approves you. The secret starts with `whsec_` and is shown once.

## Delivery

Each event is a `POST` of the event as JSON (`Content-Type: application/json`). Answer any `2xx` within 10 seconds.

Headers, in the Standard Webhooks scheme:

| Header | Content |
| - | - |
| `webhook-id` | The event id, the same on every attempt. Dedupe on it. |
| `webhook-timestamp` | Whole Unix seconds when this attempt was signed. |
| `webhook-signature` | `v1,` followed by the base64 HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<body>`. |

## Verifying the signature

The key is the base64 content of your secret after the `whsec_` prefix. Compute the HMAC over the exact bytes you received, not over a re-serialized body, compare in constant time, and refuse a timestamp more than five minutes from your clock.

```js theme={null}
const crypto = require('node:crypto')

function verify(secret, id, timestamp, rawBody, signatureHeader) {
  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!Number.isFinite(age) || age > 300) return false // more than five minutes from our clock
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const expected = crypto.createHmac('sha256', key)
    .update(`${id}.${timestamp}.${rawBody}`).digest('base64')
  const a = Buffer.from(`v1,${expected}`)
  return signatureHeader.split(' ').some(candidate => {
    const b = Buffer.from(candidate)
    return a.length === b.length && crypto.timingSafeEqual(a, b)
  })
}
```

## grant.revoked

```json theme={null}
{
  "type": "grant.revoked",
  "id": "evt_3b1f6c0e9a2d4f7b8c5e1a2b3c4d5e6f",
  "created_at": "2026-11-05T09:30:00Z",
  "data": {
    "grant_id": "6f1c2a9e-3b7d-4c51-9e2a-0d4b8f6a1c33",
    "pilot_id": "2d9b7c1e-8f4a-4e3b-a6d5-91c0f2e7b844",
    "reason": "pilot"
  }
}
```

`reason` is `pilot` (he disconnected you in Settings), `operator` (MyPilotIndex revoked it) or `account_deleted` (he deleted his account, so the grant no longer appears in any list).

When you receive it, delete what you hold from MyPilotIndex for that pilot, as the data agreement requires. From that moment every call naming the grant answers `404`.

## Retries

A `5xx`, a `408`, a `429`, a redirect (never followed), a timeout or a connection failure is retried with growing gaps for about a day, twelve attempts in all, with the same `webhook-id` and a fresh timestamp and signature. A `4xx` other than `408` and `429` is taken as a refusal and not retried.

The exception is `grant.revoked` with reason `account_deleted`: its grant leaves every list with the account, so it cannot be reconciled later. It is retried within the same window on any answer but a `2xx`, a `4xx` included. Acknowledge it with a `2xx` once you have taken it, even if you hold nothing for that pilot.

If you missed other events, reconcile with `GET /v1/partner/grants?status=revoked&changed_since=<time>`.

## Testing

`POST /v1/partner/webhooks/test` queues one signed `ping` to your webhook URL and answers `202` before delivery. The `ping` event has an empty `data` object. If no webhook URL (https) or secret is on record the answer is `409` with reason `webhook_not_configured`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.