# Guide to PAY.JP Webhooks: Features and Best Practices

PAY.JP webhooks tell your systems about the payment activity you cannot see from a request and response: a subscription renews and charges the card, a charge is refunded or disputed, a payout is fixed, a Checkout Session completes. If you take payments in Japan with PAY.JP, webhooks are how those asynchronous changes reach your order and billing systems without polling the API.

This guide covers how PAY.JP webhooks are configured and authenticated, the events in the v1 and v2 APIs and how their payloads differ, how retries and delays behave, and the practices that keep a PAY.JP receiver reliable. PAY.JP's documentation is in Japanese. The quotations below are translated.

## What are PAY.JP webhooks?

You add webhook URLs in the PAY.JP dashboard and choose, for each, whether it receives test-mode or live-mode events. An account can have several URLs, and each one receives every event of its mode. Each event is sent as a `POST` whose body is the event's JSON.

PAY.JP does not sign webhooks. Every request includes an `X-Payjp-Webhook-Token` header holding an account-specific token, such as `whook_a09d5c1c87be4e1590a9f16516`. PAY.JP sets the token, not you, and shows it under account settings in the dashboard. Your endpoint checks that the header matches it.

PAY.JP has two API versions. The webhook mechanism is the same in both, with the same header and the same token, but the event names and the event envelope differ.

## PAY.JP webhook features

| Feature | Details |
| --- | --- |
| Configuration | PAY.JP dashboard, webhook settings: a URL and test or live mode per destination |
| Subscription model | Several URLs per account. Each receives every event for its mode |
| Method and format | `POST`, JSON (`Content-Type: application/json; charset=utf-8` in the v2 guide) |
| Authentication | Account token (`whook_...`) in `X-Payjp-Webhook-Token` |
| Signature and timestamp | None |
| Event type | `type` in the body |
| Success response | v1: HTTP `200`. v2: any `2xx`. The body is ignored |
| Response window | 10 seconds (v2 guide) |
| Retries | Every 3 minutes, at most 3 times |
| Delivery status | `pending_webhooks` on the event counts URLs that have not acknowledged it |
| Testing | Test sends of each event from the dashboard |
| Source IPs | Not published |

## Common events

The v1 API reference lists around 35 event types, and the v2 guide around 28.

| API | Area | Events |
| --- | --- | --- |
| v1 | Charges | `charge.succeeded`, `charge.failed`, `charge.updated`, `charge.refunded`, `charge.captured` |
| v1 | Customers and cards | `customer.created`, `customer.updated`, `customer.deleted`, `customer.card.created`, `customer.card.updated`, `customer.card.deleted` |
| v1 | Subscriptions | `subscription.created`, `subscription.updated`, `subscription.deleted`, `subscription.paused`, `subscription.resumed`, `subscription.canceled`, `subscription.renewed` |
| v1 | Payouts and platform | `transfer.succeeded`, `tenant.created`, `tenant.updated`, `tenant.deleted`, `tenant_transfer.succeeded` |
| Both | Disputes, terms, statements, balances | `dispute.created`, `term.created`, `term.closed`, `statement.created`, `balance.created`, `balance.fixed`, `balance.closed`, `balance.merged` |
| v2 | Payment flows | `payment_flow.created`, `payment_flow.succeeded`, `payment_flow.canceled`, `payment_flow.amount_capturable_updated`, `payment_flow.payment_failed`, `payment_flow.requires_action`, `payment_flow.processing` |
| v2 | Setup flows and payment methods | `setup_flow.succeeded`, `setup_flow.setup_failed`, `payment_method.attached`, `payment_method.detached`, `payment_method.updated` |
| v2 | Checkout and refunds | `checkout.session.completed`, `checkout.session.expired`, `refund.created`, `refund.updated`, `refund.failed` |
| v2 | Catalogue | `product.*`, `price.*`, `tax_rate.created`, `tax_rate.updated` |

The v2 guide also lists the customer events.

A v1 event wraps the API object in `data`. Here is PAY.JP's example for a successful ¥5,000 charge, trimmed:

```json
{
  "created": 1442212986,
  "data": {
    "amount": 5000,
    "captured": true,
    "currency": "jpy",
    "id": "ch_bcb7776459913c743c20e9f9351d4",
    "livemode": false,
    "object": "charge",
    "paid": true,
    "refunded": false,
    "subscription": null
  },
  "id": "evnt_5328acdbdb5294d6fc9cc903f8c",
  "livemode": false,
  "object": "event",
  "pending_webhooks": 1,
  "type": "charge.succeeded"
}

```

`created` is in Unix seconds, and `data` has the same shape as the API response for that object. A v2 event has the same `id`, `object`, `livemode`, `type`, `pending_webhooks` and `data`, but replaces `created` with `created_at` and `updated_at` as ISO 8601 strings in UTC. If one endpoint receives both, check which timestamp field is present.

> **Inspect PAY.JP webhooks as they arrive.** Add a [Hookdeck Inspect](https://inspect.hookdeck.com) URL as a test-mode destination and send a test event from the dashboard to see the exact headers and body, with no account or setup required.

## Setting up PAY.JP webhooks

1. Log in to PAY.JP and open the webhook settings in the dashboard.
2. Enter your HTTPS URL, choose test mode or live mode, and click add.
3. Use the dashboard's test send to deliver a sample event.
4. Open account settings and copy the `X-Payjp-Webhook-Token` value. Store it server-side, for example as `PAYJP_WEBHOOK_TOKEN`.

To route through Hookdeck, create a Hookdeck source with the **PAY.JP** type and add its URL as a webhook destination. The source has one field, **Webhook Token**: use the webhook token (`whook_...`) shown in your PAY.JP account settings. Hookdeck reads it from the `X-Payjp-Webhook-Token` header and compares the whole value. The source accepts `POST` only, which is the method PAY.JP uses.

## Securing PAY.JP webhooks

Read `X-Payjp-Webhook-Token`, compare it with your stored token in constant time, and reject the request if either is missing. PAY.JP's v2 example compares with `!==`. A constant-time comparison is the safer habit.

```javascript
const crypto = require('crypto');
const express = require('express');

const WEBHOOK_TOKEN = process.env.PAYJP_WEBHOOK_TOKEN; // whook_...

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/payjp', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('x-payjp-webhook-token');
  if (!WEBHOOK_TOKEN || typeof received !== 'string' || !safeEqual(received, WEBHOOK_TOKEN)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Skip event.id if already processed, store the event, then route on event.type.
  res.sendStatus(200); // v1 requires exactly 200
});

```

The same check in Python:

```python
import hmac
import os

WEBHOOK_TOKEN = os.environ.get("PAYJP_WEBHOOK_TOKEN", "")

def verify_payjp_request(headers) -> bool:
    if not WEBHOOK_TOKEN:
        return False  # never accept when unconfigured
    received = headers.get("X-Payjp-Webhook-Token")  # case-insensitive in Starlette and Flask
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), WEBHOOK_TOKEN.encode("utf-8"))

```

The token is static and account-wide. It authenticates the sender, not the body, and with no timestamp a captured request can be replayed. PAY.JP asks you to receive webhooks over HTTPS in production, because events carry customer and payment details. For anything that moves money, re-read the object from the API before acting on the event.

> **Make PAY.JP webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `X-Payjp-Webhook-Token` header, answers PAY.JP inside its 10-second window, and queues every charge and subscription event durably before your service sees it.

## PAY.JP webhook limitations and pain points

### Three retries, nine minutes

**The Problem:** A short outage on your side means events never arrive, and subscription renewals or refunds go unrecorded.

**Why It Happens:** PAY.JP retries a failed delivery every 3 minutes, at most 3 times. After that, delivery stops, and `pending_webhooks` on the event stays above zero. PAY.JP says it often sees webhooks go undelivered because the receiving endpoint kept failing, through server errors or an expired SSL certificate, until the retries ran out.

**Workarounds:**

* Respond with `200` as soon as the token is checked and the event is stored.
* Monitor your certificate expiry and your endpoint's error rate.
* List events through the API and look for ones with `pending_webhooks` above zero, then resend from the dashboard or reconcile from the objects themselves.

**How Hookdeck Can Help:** Hookdeck accepts each event as soon as it arrives and stores it, so PAY.JP's three retries are rarely needed. Hookdeck then delivers to your service with [retries](/docs/retries) you control, and you can replay any event after a fix.

### Duplicates and delays

**The Problem:** The same event arrives twice, or a renewal event arrives hours after the charge.

**Why It Happens:** PAY.JP says the same webhook can be sent more than once. It also warns that delivery can be delayed during peak periods, such as the start and end of each month and around the top of each hour, during maintenance and during incidents, and recommends designing for delays of several hours at month end.

**Workarounds:**

* Store each event `id` for a while and skip events you have already handled, or make the handler idempotent so a repeat produces the same state.
* Do not treat a missing event as proof that nothing happened. For subscriptions, PAY.JP suggests recording the last event time per subscription and checking the subscription through the API when it is more than a month and a day old.

**How Hookdeck Can Help:** Hookdeck [deduplication](/docs/deduplication) can drop repeated deliveries based on the event `id`, and [issues](/docs/issues) alert you when deliveries to your service start failing.

### One token, no rotation of your own

**The Problem:** You cannot choose or easily replace the credential that authenticates PAY.JP.

**Why It Happens:** The token belongs to the account, and PAY.JP sets it. Every URL on the account receives the same one.

**Workarounds:**

* Keep the token in a secrets manager, never in client code or logs.
* Combine the check with HTTPS and with re-reading important objects through the API.

**How Hookdeck Can Help:** Hookdeck's PAY.JP source compares the header with the token you enter. Hookdeck answers with a `200` either way, but requests that fail the check are recorded as failed verification and never reach your service.

### Two API versions, two envelopes

**The Problem:** A handler written for v1 breaks on a v2 event, or the reverse.

**Why It Happens:** v1 events carry `created` in Unix seconds and v1 event names such as `charge.succeeded`. v2 events carry `created_at` and `updated_at` as ISO strings and names such as `payment_flow.succeeded`. Both arrive with the same header at the same URLs.

**Workarounds:**

* Branch on whether `created` or `created_at` is present before reading timestamps.
* Route on `type`, and log and acknowledge any type you do not recognise.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route v1 and v2 events to different handlers based on `type` or the presence of `created_at`.

## Best practices

### Log and keep what you receive

PAY.JP recommends recording request and error logs for your webhook endpoint and keeping the received data for a while, so you can replay processing after a bug instead of asking for resends.

### Deduplicate on the event `id`

Store each event `id` before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Separate test and live

Register test-mode and live-mode URLs separately, and check `livemode` on every event before it touches production data.

### Reconcile on a schedule

Webhooks are your main signal, but not your only one. Compare subscriptions, disputes and balances with the API periodically, especially around month end.

### Acknowledge fast and process asynchronously

Check the token, store the event, return `200` within 10 seconds, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

PAY.JP sends every event as a JSON `POST` with an account token in `X-Payjp-Webhook-Token` and no signature. Compare the token in constant time over HTTPS, return `200` within 10 seconds, deduplicate on the event `id`, handle both the v1 and v2 envelopes, and reconcile with the API, because three retries over nine minutes leave little room for an outage.

[Hookdeck Event Gateway](https://hookdeck.com) checks the token, acknowledges PAY.JP immediately and keeps every event until your service has processed it, so a failing endpoint no longer means lost charges and renewals.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle PAY.JP webhooks reliably in minutes.