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-Token header?
  • Does PAY.JP sign webhook payloads? (No: use the shared token)
  • How do I handle charge.succeeded, charge.failed or subscription.renewed events?
  • 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 401 on a missing or wrong token, 400 for a body that isn't a JSON object, and 200 once handled. PAY.JP requires HTTP 200 (the body is ignored); 4xx/5xx responses 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_webhooks}. Dispatch on type; data is the same object the API returns.

EventSent When
charge.succeededA payment succeeds
charge.failedA payment fails
charge.updatedA charge is updated
charge.refundedA charge is refunded
charge.capturedAn authorized charge is captured
dispute.createdA chargeback occurs
customer.createdA customer is created
subscription.createdA subscription is created
subscription.renewedA subscription period renews
subscription.canceledA subscription is canceled
transfer.succeededA 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 10, 2026

View on GitHub →