Gareth Wilson Gareth Wilson

Guide to PAY.JP Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPAY.JP dashboard, webhook settings: a URL and test or live mode per destination
Subscription modelSeveral URLs per account. Each receives every event for its mode
Method and formatPOST, JSON (Content-Type: application/json; charset=utf-8 in the v2 guide)
AuthenticationAccount token (whook_...) in X-Payjp-Webhook-Token
Signature and timestampNone
Event typetype in the body
Success responsev1: HTTP 200. v2: any 2xx. The body is ignored
Response window10 seconds (v2 guide)
RetriesEvery 3 minutes, at most 3 times
Delivery statuspending_webhooks on the event counts URLs that have not acknowledged it
TestingTest sends of each event from the dashboard
Source IPsNot published

Common events

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

APIAreaEvents
v1Chargescharge.succeeded, charge.failed, charge.updated, charge.refunded, charge.captured
v1Customers and cardscustomer.created, customer.updated, customer.deleted, customer.card.created, customer.card.updated, customer.card.deleted
v1Subscriptionssubscription.created, subscription.updated, subscription.deleted, subscription.paused, subscription.resumed, subscription.canceled, subscription.renewed
v1Payouts and platformtransfer.succeeded, tenant.created, tenant.updated, tenant.deleted, tenant_transfer.succeeded
BothDisputes, terms, statements, balancesdispute.created, term.created, term.closed, statement.created, balance.created, balance.fixed, balance.closed, balance.merged
v2Payment flowspayment_flow.created, payment_flow.succeeded, payment_flow.canceled, payment_flow.amount_capturable_updated, payment_flow.payment_failed, payment_flow.requires_action, payment_flow.processing
v2Setup flows and payment methodssetup_flow.succeeded, setup_flow.setup_failed, payment_method.attached, payment_method.detached, payment_method.updated
v2Checkout and refundscheckout.session.completed, checkout.session.expired, refund.created, refund.updated, refund.failed
v2Catalogueproduct.*, 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

  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.

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-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 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 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 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 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.