Agent skill
PAY.JP Webhooks Skill
Receive and authenticate PAY.JP webhooks. Use when setting up a PAY.JP webhook endpoint, checking the X-Payjp-Webhook-Token header, debugging 401s, or handling events like charge.succeeded, charge.failed, charge.refunded, subscription.renewed and transfer.succeeded.
Install this skill
npx skills add hookdeck/webhook-skills --skill payjp-webhooks
PAY.JP (Japanese payments) sends a JSON webhook to your URL when something happens in your account: a charge succeeds or fails, a subscription renews, a transfer is confirmed. Webhooks are not signed. Every request is a POST that carries an X-Payjp-Webhook-Token header holding your account's webhook token (whook_...), and your endpoint authenticates the request by comparing that header with the token you have stored.
When to Use This Skill
- How do I receive PAY.JP webhooks?
- How do I verify the
X-Payjp-Webhook-Tokenheader? - Does PAY.JP sign webhook payloads? (No: use the shared token)
- How do I handle
charge.succeeded,charge.failedorsubscription.renewedevents? - Why does my PAY.JP webhook endpoint keep getting retried?
Verification (core)
const crypto = require('crypto');
// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
function safeEqual(a, b) {
const ha = crypto.createHash('sha256').update(String(a)).digest();
const hb = crypto.createHash('sha256').update(String(b)).digest();
return crypto.timingSafeEqual(ha, hb);
}
function verifyPayjpRequest(headers, secret = process.env.PAYJP_WEBHOOK_SECRET) {
if (!secret) return false; // never accept when unconfigured
const received = headers['x-payjp-webhook-token']; // whole value, e.g. whook_a09d...
return Boolean(received) && safeEqual(received, secret);
}
- Header:
X-Payjp-Webhook-Token: whook_, the token set by PAY.JP for your account (you don't choose it). Compare the whole value, case-insensitive header name.... - Hookdeck shows this setup note for the PAY.JP source: "Use the webhook token (whook_...) shown in your PAY.JP account settings."
- Return
401on a missing or wrong token,400for a body that isn't a JSON object, and200once handled. PAY.JP requires HTTP 200 (the body is ignored);4xx/5xxresponses are retried every 3 minutes, at most 3 times. - Read the body raw (
express.raw({ type: '*/*' })) so any content type works, authenticate first, and parse afterwards. - A static shared token authenticates the sender, not the body, and gives no replay protection (no signature, no timestamp). Always receive webhooks over HTTPS and rotate the token if it leaks. Dedupe on the event
id.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Event Types
The body is a v1 event {id, object: "event", livemode, type, created, data, pending_. Dispatch on type; data is the same object the API returns.
| Event | Sent When |
|---|---|
charge.succeeded | A payment succeeds |
charge.failed | A payment fails |
charge.updated | A charge is updated |
charge.refunded | A charge is refunded |
charge.captured | An authorized charge is captured |
dispute.created | A chargeback occurs |
customer.created | A customer is created |
subscription.created | A subscription is created |
subscription.renewed | A subscription period renews |
subscription.canceled | A subscription is canceled |
transfer.succeeded | A transfer (payout) is confirmed |
See references/overview.md for the full list.
Environment Variables
PAYJP_WEBHOOK_SECRET=whook_xxxxxxxxxxxxxxxxxxxxxxxxxx # PAY.JP account settings (アカウント設定)
Local Development
PAY.JP can't reach localhost. Tunnel with the Hookdeck CLI (no account required):
npx hookdeck-cli listen 3000 payjp --path /webhooks/payjp
Use the printed URL as the webhook destination in the PAY.JP dashboard.
Resources
- references/overview.md - What PAY.JP webhooks are, events and payload shape
- references/setup.md - Add the destination and find the webhook token
- references/verification.md - Token check details, gotchas, debugging 401s
- examples/ - Runnable Express, Next.js and FastAPI handlers with tests