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_. 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_ |
| 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_, payment_, payment_, 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:
{
"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 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
- Log in to PAY.JP and open the webhook settings in the dashboard.
- Enter your HTTPS URL, choose test mode or live mode, and click add.
- Use the dashboard's test send to deliver a sample event.
- Open account settings and copy the
X-Payjp-Webhook-Tokenvalue. Store it server-side, for example asPAYJP_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.
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:
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 checks the
X-Payjp-Webhook-Tokenheader, 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
200as 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_webhooksabove 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 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
idfor 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 can drop repeated deliveries based on the event id, and 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
createdorcreated_atis present before reading timestamps. - Route on
type, and log and acknowledge any type you do not recognise.
How Hookdeck Can Help: Hookdeck 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.
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.
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 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 for free and handle PAY.JP webhooks reliably in minutes.