> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.thepennyinc.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.thepennyinc.com/_mcp/server.

# Subscribing to Webhooks

See [Webhooks Overview](/platform/webhooks-overview) first for delivery behavior, and the [Event Catalog](/platform/webhooks-event-catalog) for the full list of event identifiers you can subscribe to.

## 1. Register an endpoint

**`cURL`**

```bash title="cURL"
RESPONSE=$(curl -s -X POST https://sandbox.api.thepennyinc.com/notifications/webhooks/ \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/penny",
    "trigger_events": ["card.issued", "card.terminated", "transaction.created"],
    "authentication_token": "a-bearer-token-you-generate-and-check-for"
  }')
ENDPOINT_ID=$(echo "$RESPONSE" | jq -r '.notification_endpoint_id')
```

**`Python`**

```python title="Python"
import requests

BANKING = "https://sandbox.api.thepennyinc.com"
headers = {"Authorization": f"Bearer {access_token}"}

webhook = requests.post(
    f"{BANKING}/notifications/webhooks/",
    headers=headers,
    json={
        "url": "https://example.com/webhooks/penny",
        "trigger_events": ["card.issued", "card.terminated", "transaction.created"],
        "authentication_token": "a-bearer-token-you-generate-and-check-for",
    },
).json()
notification_endpoint_id = webhook["notification_endpoint_id"]
```

**`JavaScript`**

```javascript title="JavaScript"
const BANKING = "https://sandbox.api.thepennyinc.com";
const headers = {
  Authorization: `Bearer ${accessToken}`,
  "Content-Type": "application/json",
};

const webhook = await fetch(`${BANKING}/notifications/webhooks/`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    url: "https://example.com/webhooks/penny",
    trigger_events: ["card.issued", "card.terminated", "transaction.created"],
    authentication_token: "a-bearer-token-you-generate-and-check-for",
  }),
}).then((res) => res.json());
const notificationEndpointId = webhook.notification_endpoint_id;
```

`authentication_token` is optional. If set, Penny sends it as `Authorization: Bearer <token>` on each delivery; see [Webhook Authorization](/platform/webhook-authorization). You can also pass `filter_identifiers` here; see Step 6.

## 2. Wait for provisioning

Creation is asynchronous: `POST /notifications/webhooks/` returns `202 Accepted` with `"status": "pending_setup"` and `"updating": true` while Penny provisions the delivery infrastructure. The webhook isn't ready to verify yet. Poll `GET /notifications/webhooks/{notification_endpoint_id}` until `status` reaches `pending_verification`, then move on to [Step 3](#3-verify-the-endpoint).

**`cURL`**

```bash title="cURL"
until [ "$STATUS" = "pending_verification" ]; do
  sleep 2
  STATUS=$(curl -s "https://sandbox.api.thepennyinc.com/notifications/webhooks/$ENDPOINT_ID" \
    -H "Authorization: Bearer $ACCESS_TOKEN" | jq -r '.status')
  if [ "$STATUS" = "setup_failed" ]; then echo "setup failed"; break; fi
done
```

**`Python`**

```python title="Python"
import time

while True:
    webhook = requests.get(
        f"{BANKING}/notifications/webhooks/{notification_endpoint_id}", headers=headers
    ).json()
    if webhook["status"] == "pending_verification":
        break
    if webhook["status"] == "setup_failed":
        raise RuntimeError(f"setup failed: {webhook['verification']['reason']}")
    time.sleep(2)
```

**`JavaScript`**

```javascript title="JavaScript"
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

let status;
do {
  await sleep(2000);
  const webhook = await fetch(`${BANKING}/notifications/webhooks/${notificationEndpointId}`, {
    headers,
  }).then((res) => res.json());
  status = webhook.status;
  if (status === "setup_failed") {
    throw new Error(`setup failed: ${webhook.verification.reason}`);
  }
} while (status !== "pending_verification");
```

`status` is one of nine values:

| Status                 | Meaning                                    | What to do                                                                         |
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------- |
| `pending_setup`        | Infrastructure is being provisioned        | Wait and poll                                                                      |
| `setup_failed`         | Provisioning failed                        | Contact Penny Support                                                              |
| `pending_verification` | Ready to verify                            | Proceed to Step 3                                                                  |
| `verification_failed`  | The challenge wasn't returned correctly    | Check `verification.reason`; re-trigger verification                               |
| `updating`             | A configuration change is being applied    | Wait and poll                                                                      |
| `active`               | Verified and delivering events             | Normal operation                                                                   |
| `deactivated`          | Manually paused                            | Call `PATCH /notifications/webhooks/{notification_endpoint_id}/activate` to resume |
| `suspended`            | Suspended by Penny                         | Check `suspension.reason`                                                          |
| `archived`             | Archived with `DELETE`; no longer delivers | Create a new webhook if needed                                                     |

Both `verification.reason` (why setup or verification failed) and `suspension.reason` (why Penny suspended the webhook) are fields on the `GET /notifications/webhooks/{notification_endpoint_id}` response.

## 3. Verify the endpoint

A newly created webhook doesn't deliver events until it's verified. Verification confirms that you control the URL you registered.

`POST /notifications/webhooks/{notification_endpoint_id}/verification` sends a challenge to your URL. The challenge arrives like any other delivery, with `"type": "endpoint.verification.pending"` and `"category": "verification"`; its `data.token` is the verification code. Submit that code back with `PATCH` on the same path:

**`cURL`**

```bash title="cURL"
# Send a challenge to your endpoint
curl -X POST "https://sandbox.api.thepennyinc.com/notifications/webhooks/$ENDPOINT_ID/verification" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

# Submit the challenge's data.token as verification_code
curl -X PATCH "https://sandbox.api.thepennyinc.com/notifications/webhooks/$ENDPOINT_ID/verification" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"verification_code": "the-code-your-endpoint-received"}'
```

**`Python`**

```python title="Python"
# Send a challenge to your endpoint
requests.post(f"{BANKING}/notifications/webhooks/{notification_endpoint_id}/verification", headers=headers)

# Submit the challenge's data.token as verification_code
requests.patch(
    f"{BANKING}/notifications/webhooks/{notification_endpoint_id}/verification",
    headers=headers,
    json={"verification_code": "the-code-your-endpoint-received"},
)
```

**`JavaScript`**

```javascript title="JavaScript"
// Send a challenge to your endpoint
await fetch(`${BANKING}/notifications/webhooks/${notificationEndpointId}/verification`, {
  method: "POST",
  headers,
});

// Submit the challenge's data.token as verification_code
await fetch(`${BANKING}/notifications/webhooks/${notificationEndpointId}/verification`, {
  method: "PATCH",
  headers,
  body: JSON.stringify({ verification_code: "the-code-your-endpoint-received" }),
});
```

Only a verified, active webhook receives deliveries. Your receiver must accept the challenge request before verification completes, so handle `endpoint.verification.pending` before you check for subscribed event types.

## 4. Configure request signing (optional)

Webhook creation does not generate a signing secret. After provisioning finishes, create one:

```http
POST /notifications/webhooks/{notification_endpoint_id}/signing_secret
```

```json
{ "signing_secret": "penny_whsec_...", "algorithm": "HMAC-SHA256" }
```

Save the secret from this response. Webhook responses never return it, including with `reveal_sensitive=true`; `has_signing_secret` indicates whether signing is configured. Creating a second secret fails. To replace an existing secret, use `POST /notifications/webhooks/{notification_endpoint_id}/signing_secret/rotate`, which returns the same response shape. Rotation immediately changes the key used for new attempts.

Once a secret exists, each request carries `Penny-Webhook-Timestamp` and `Penny-Webhook-Signature` headers. Verify them using the entire secret string, including its prefix, as described in [Verify Webhook Signatures](/platform/webhook-hmac).

## 5. Adjust which events it receives

Add or remove event identifiers without recreating the webhook:

**`cURL`**

```bash title="cURL"
curl -X PATCH "https://sandbox.api.thepennyinc.com/notifications/webhooks/$ENDPOINT_ID/trigger-events" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"trigger_events": ["cardholder.updated"]}'

curl -X DELETE "https://sandbox.api.thepennyinc.com/notifications/webhooks/$ENDPOINT_ID/trigger-events" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"trigger_events": ["transaction.created"]}'
```

**`Python`**

```python title="Python"
requests.patch(
    f"{BANKING}/notifications/webhooks/{notification_endpoint_id}/trigger-events",
    headers=headers,
    json={"trigger_events": ["cardholder.updated"]},
)

requests.delete(
    f"{BANKING}/notifications/webhooks/{notification_endpoint_id}/trigger-events",
    headers=headers,
    json={"trigger_events": ["transaction.created"]},
)
```

**`JavaScript`**

```javascript title="JavaScript"
await fetch(`${BANKING}/notifications/webhooks/${notificationEndpointId}/trigger-events`, {
  method: "PATCH",
  headers,
  body: JSON.stringify({ trigger_events: ["cardholder.updated"] }),
});

await fetch(`${BANKING}/notifications/webhooks/${notificationEndpointId}/trigger-events`, {
  method: "DELETE",
  headers,
  body: JSON.stringify({ trigger_events: ["transaction.created"] }),
});
```

## 6. Scope to specific objects (optional)

`filter_identifiers` narrows delivery to specific object IDs — for example, only transactions on one account, rather than every transaction on the business:

**`cURL`**

```bash title="cURL"
curl -X PUT "https://sandbox.api.thepennyinc.com/notifications/webhooks/$ENDPOINT_ID/filter-identifiers" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"filter_identifiers": ["account_019372e3-b8dc-7de2-80b6-b8dcde20b6c9"]}'
```

**`Python`**

```python title="Python"
requests.put(
    f"{BANKING}/notifications/webhooks/{notification_endpoint_id}/filter-identifiers",
    headers=headers,
    json={"filter_identifiers": ["account_019372e3-b8dc-7de2-80b6-b8dcde20b6c9"]},
)
```

**`JavaScript`**

```javascript title="JavaScript"
await fetch(`${BANKING}/notifications/webhooks/${notificationEndpointId}/filter-identifiers`, {
  method: "PUT",
  headers,
  body: JSON.stringify({ filter_identifiers: ["account_019372e3-b8dc-7de2-80b6-b8dcde20b6c9"] }),
});
```

## 7. Inspect delivery history

`GET /notifications/webhooks/events` lists your business's events. The `identifier` query parameter is optional; pass an event type to return only events of that type. Delivery status lives on each delivery, not on the event, so list an event's deliveries to see whether it reached your endpoint:

**`cURL`**

```bash title="cURL"
curl "https://sandbox.api.thepennyinc.com/notifications/webhooks/events?identifier=card.issued" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
curl "https://sandbox.api.thepennyinc.com/notifications/webhooks/events/$NOTIFICATION_EVENT_ID/deliveries" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

**`Python`**

```python title="Python"
events = requests.get(
    f"{BANKING}/notifications/webhooks/events",
    headers=headers,
    params={"identifier": "card.issued"},  # optional
).json()
deliveries = requests.get(
    f"{BANKING}/notifications/webhooks/events/{notification_event_id}/deliveries",
    headers=headers,
).json()
# Each delivery's "status" is one of: pending, pending_retry, delivered, failed
```

**`JavaScript`**

```javascript title="JavaScript"
const events = await fetch(
  `${BANKING}/notifications/webhooks/events?identifier=card.issued`, // identifier is optional
  { headers }
).then((res) => res.json());
const deliveries = await fetch(
  `${BANKING}/notifications/webhooks/events/${notificationEventId}/deliveries`,
  { headers }
).then((res) => res.json());
// Each delivery's status is one of: pending, pending_retry, delivered, failed
```

`GET /notifications/webhooks/events/{notification_event_id}` returns a single event, including the exact `payload` that was delivered. `GET /notifications/webhooks/{notification_endpoint_id}/deliveries` lists deliveries to one endpoint. See [Delivery status](/platform/webhooks-overview#delivery-status) for the delivery fields and [Delivery behavior](/platform/webhooks-overview#delivery-behavior) for the retry schedule.

## Pausing or archiving a webhook

**`cURL`**

```bash title="cURL"
curl -X PATCH "https://sandbox.api.thepennyinc.com/notifications/webhooks/$ENDPOINT_ID/deactivate" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
curl -X DELETE "https://sandbox.api.thepennyinc.com/notifications/webhooks/$ENDPOINT_ID" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

**`Python`**

```python title="Python"
requests.patch(f"{BANKING}/notifications/webhooks/{notification_endpoint_id}/deactivate", headers=headers)
requests.delete(f"{BANKING}/notifications/webhooks/{notification_endpoint_id}", headers=headers)
```

**`JavaScript`**

```javascript title="JavaScript"
await fetch(`${BANKING}/notifications/webhooks/${notificationEndpointId}/deactivate`, {
  method: "PATCH",
  headers,
});
await fetch(`${BANKING}/notifications/webhooks/${notificationEndpointId}`, { method: "DELETE", headers });
```

`PATCH .../deactivate` pauses deliveries and keeps the configuration; resume with `PATCH .../activate`. `DELETE /notifications/webhooks/{notification_endpoint_id}` archives the webhook and returns `202 Accepted`. An archived webhook moves to `archived` status and no longer receives deliveries; create a new webhook if you need the URL again.

## Managing the authentication token

`authentication_token` is write-only. Supply your own token when creating the webhook, or replace it with `PATCH /notifications/webhooks/{notification_endpoint_id}`. Omit the field to keep the existing token; send `"authentication_token": null` to remove it. Changing or removing the token requires ownership verification again.

Webhook responses expose `has_authentication_token` instead of the token. The token is never returned, including with `reveal_sensitive=true` or in history responses. Store your token before sending it to Penny; no token-retrieval endpoint is provided.