Ping

Webhook Signature Verification

Ping signs all outgoing callbacks with HMAC-SHA256 signatures. This allows you to verify that callbacks are genuinely from Ping and haven't been tampered with.


Signature Header

Every callback includes an X-Ping-Signature header:

X-Ping-Signature: t=1768483312,v1=21c1746916fac3c5e98835f5c00ea78081a122a26843fbff05961bd9cf059ec9
  • t - Unix timestamp when the signature was generated
  • v1 - HMAC-SHA256 signature of "timestamp.payload"

Getting Your Webhook Secret

Each channel has a unique secret for signature verification:

Terminal
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://ping-api-production.up.railway.app/v1/channels/YOUR_CHANNEL_ID/secret"

Response:

JSON
{
  "webhook_secret": "4140db9a5fa04fe3208183ceb1d659cafbfa23ff3f31e7cc3777fd3062e60992",
  "algorithm": "HMAC-SHA256",
  "header_name": "X-Ping-Signature"
}

Verification Steps

  1. Extract t (timestamp) and v1 (signature) from the header
  2. Check timestamp is within 5 minutes (replay protection)
  3. Reconstruct: "${timestamp}.${rawBody}"
  4. Compute HMAC-SHA256 with your webhook secret
  5. Compare using timing-safe comparison

Quick Verification (Node.js)

const crypto = require('crypto');

function verifySignature(rawBody, signatureHeader, secret) {
  const [tPart, sigPart] = signatureHeader.split(',');
  const timestamp = parseInt(tPart.split('=')[1]);
  const signature = sigPart.split('=')[1];

  // Check timestamp (5 min tolerance)
  if (Math.abs(Date.now()/1000 - timestamp) > 300) return false;

  // Verify signature
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex')
  );
}

Quick Verification (Python)

import hmac
import hashlib
import time

def verify_signature(raw_body: str, signature_header: str, secret: str) -> bool:
    parts = dict(part.split('=', 1) for part in signature_header.split(','))
    timestamp = int(parts['t'])
    signature = parts['v1']

    # Check timestamp (5 min tolerance)
    if abs(time.time() - timestamp) > 300:
        return False

    # Verify signature
    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.{raw_body}".encode(),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected)

Rotating Secrets

If your secret is compromised:

Terminal
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \
  "https://ping-api-production.up.railway.app/v1/channels/YOUR_CHANNEL_ID/secret/rotate"

The new secret takes effect immediately. Old signatures will fail verification.

Debugging Signatures

Use the public verification endpoints to debug issues:

Generate a Test Signature

Terminal
curl -X POST "https://ping-api-production.up.railway.app/v1/verify/generate" \
  -H "Content-Type: application/json" \
  -d '{"payload": "{\"test\":true}", "secret": "YOUR_SECRET"}'

Verify a Signature

Terminal
curl -X POST "https://ping-api-production.up.railway.app/v1/verify/signature" \
  -H "Content-Type: application/json" \
  -d '{"payload": "...", "signature": "t=...,v1=...", "secret": "YOUR_SECRET"}'

Get Documentation

Terminal
curl "https://ping-api-production.up.railway.app/v1/verify/docs"

Common Issues

Timestamp Drift

If your server clock is off, you may fail the 5-minute check. Use NTP to sync your server time.

Raw Body Encoding

The signature is computed on the raw request body bytes. Make sure you're using the raw body before any JSON parsing.

Secret Handling

Store your webhook secret securely. Don't commit it to version control.