Verify webhook signatures

Check that a webhook really comes from ProcessOut and that nobody changed it on the way. Where to get your signing secret, how to check the signature, and how to rotate the secret without dropping deliveries.

Every webhook we send to your endpoint carries a ProcessOut-Signature header. It is an HMAC-SHA256 of the request body, computed with a secret that only you and ProcessOut know.

When the signature checks out, you know two things:

  • the request was sent by ProcessOut, not by someone who found your webhook URL
  • the body is exactly what we sent.

Only webhooks sent to your webhook endpoints are signed. Webhooks sent to a
custom URL set on an invoice (webhook_url) are not signed.

Signatures are being enabled project by project. If your deliveries do not carry the header yet, contact support to have it enabled for your project.

How it works

  1. Each webhook endpoint has its own signing secret, for example
    whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8.

  2. For every delivery, we compute an HMAC-SHA256 over the raw request body using that secret and send the result, hex-encoded, in the ProcessOut-Signature header.

  3. Your server computes the same value from the body it received and compares the two. If they match, the request is genuine.

Nothing else about the request changes. The body and your custom headers stay the same.

Demo values only

🚧

Demo values only. All secrets, signatures, event IDs and request bodies on this page are demo values for illustration and testing only. They are not real credentials. Do not copy them into real environments (test or live). Always use the signing secret generated for your own webhook endpoint.

Step 1: Get your signing secret

The secret is shown only once, right after it is created. We cannot show it again. Store it the
way you store your API keys, in a secrets manager or an environment variable. Never commit it or write it to logs.

From the Dashboard

  • New endpoint: create it under Developers › Webhook endpoints. The signing secret is shown in a window right after the endpoint is created.
  • Existing endpoint: open the endpoint's action menu and choose Generate signing secret (or Rotate signing secret if it already has one).

Endpoints created before signing was available

These endpoints have no secret. Their secrets list is empty and their deliveries are not signed.

To give one a secret, use Generate signing secret in the Dashboard, or call the
rotation endpoint once. It creates the first secret.

Test mode and live mode

Every endpoint has its own secret. Test-mode endpoints and live endpoints never share one, so keep a separate secret for each endpoint you receive webhooks on.

Step 2: Read the header

A normal delivery carries one signature:

ProcessOut-Signature: v1=2dad8a6f8903b53809e36e486ed36d5bc4b1beed4d9ac785478ae64023b58967

Right after a secret rotation, it can carry one signature per valid secret:

ProcessOut-Signature: v1=2dad8a6f8903b53809e36e486ed36d5bc4b1beed4d9ac785478ae64023b58967, v1=c18b8b0aa2e7c48c0313d72b7a17b0c711233609abeaeabadb44e0c2e5a0b80b

How to read it:

  • The header name is case-insensitive, like every HTTP header. It arrives as
    Processout-Signature, and some tools show it as processout-signature. Look it up with your
    framework's header accessor, not with a case-sensitive string match.
  • Entries are separated by commas. Trim the spaces around each one.
  • Each entry is scheme=value. v1 means HMAC-SHA256, written as 64 lowercase hex characters.
  • Ignore any entry whose scheme is not v1. We may add new schemes later, and your code should keep working when we do.
  • The order of the entries is not guaranteed. There are at most four.
  • The request is genuine if any v1 entry matches any secret you hold. You do not need all of them to match.

Step 3: Verify the signature

For each secret you currently hold (usually one, two during a rotation):

  1. Take the raw request body, byte for byte, exactly as it arrived. Do this before any JSON
    parsing. A body that has been parsed and serialised again will not match.
  2. Remove the whsec_ prefix from the secret. Base64url-decode the rest (the URL-safe
    alphabet, with no = padding). You get 32 bytes. This is the HMAC key.
  3. Compute HMAC-SHA256 of the raw body with those 32 bytes as the key.
  4. Compare the result with each v1 value in the header. Use a constant-time comparison,
    not ==, so the comparison does not leak timing information.

If nothing matches, reply with 401 and do not process the event.

Answering 401 is safe. A rejected delivery is retried on the normal schedule for up to 72 hours, and every retry is signed again with the endpoint's current secrets. If the problem was a wrong secret in your configuration, fix it and the retries go through. See
Webhook delivery and retries.

The examples below take secrets as a list, so the same code keeps working during a rotation.

package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"io"
	"net/http"
	"strings"
)

// VerifySignature reports whether header holds a valid v1 signature of body for any of secrets.
func VerifySignature(body []byte, header string, secrets []string) bool {
	for _, secret := range secrets {
		key, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
		if err != nil {
			continue
		}
		mac := hmac.New(sha256.New, key)
		mac.Write(body)
		expected := mac.Sum(nil)

		for _, entry := range strings.Split(header, ",") {
			scheme, value, ok := strings.Cut(strings.TrimSpace(entry), "=")
			if !ok || scheme != "v1" {
				continue
			}
			received, err := hex.DecodeString(value)
			if err == nil && hmac.Equal(received, expected) {
				return true
			}
		}
	}
	return false
}

func HandleWebhook(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "cannot read body", http.StatusBadRequest)
		return
	}
	if !VerifySignature(body, r.Header.Get("ProcessOut-Signature"), signingSecrets()) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}
	w.WriteHeader(http.StatusOK)
	// Now parse body and fetch the event by its event_id.
}
import base64
import hashlib
import hmac

from flask import Flask, abort, request

app = Flask(__name__)


def verify_signature(body: bytes, header: str, secrets: list[str]) -> bool:
    for secret in secrets:
        encoded = secret.removeprefix("whsec_")
        key = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
        expected = hmac.new(key, body, hashlib.sha256).hexdigest()
        for entry in header.split(","):
            scheme, _, value = entry.strip().partition("=")
            if scheme == "v1" and hmac.compare_digest(value.encode(), expected.encode()):
                return True
    return False


@app.post("/webhooks/processout")
def processout_webhook():
    body = request.get_data()  # raw bytes: do not use request.json here
    header = request.headers.get("ProcessOut-Signature", "")
    if not verify_signature(body, header, SIGNING_SECRETS):
        abort(401)
    # Now parse body and fetch the event by its event_id.
    return "", 200
const crypto = require('crypto');
const express = require('express');

function verifySignature(rawBody, header, secrets) {
  for (const secret of secrets) {
    const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64url');
    const expected = crypto.createHmac('sha256', key).update(rawBody).digest();
    for (const entry of (header || '').split(',')) {
      const [scheme, value] = entry.trim().split('=');
      if (scheme !== 'v1' || !value) continue;
      const received = Buffer.from(value, 'hex');
      if (received.length === expected.length && crypto.timingSafeEqual(received, expected)) {
        return true;
      }
    }
  }
  return false;
}

const app = express();

// express.raw keeps the body as a Buffer. Register it on this route, before any JSON parser.
app.post('/webhooks/processout', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifySignature(req.body, req.get('ProcessOut-Signature'), signingSecrets)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);
  const { event_id } = JSON.parse(req.body);
  // Now fetch the event by its event_id.
});
<?php

function verifySignature(string $body, string $header, array $secrets): bool
{
    foreach ($secrets as $secret) {
        $key = base64_decode(strtr(preg_replace('/^whsec_/', '', $secret), '-_', '+/'));
        $expected = hash_hmac('sha256', $body, $key);
        foreach (explode(',', $header) as $entry) {
            $parts = explode('=', trim($entry), 2);
            if (count($parts) === 2 && $parts[0] === 'v1' && hash_equals($expected, $parts[1])) {
                return true;
            }
        }
    }
    return false;
}

$body = file_get_contents('php://input'); // raw body: do not use json_decode output here
$header = $_SERVER['HTTP_PROCESSOUT_SIGNATURE'] ?? '';

if (!verifySignature($body, $header, $signingSecrets)) {
    http_response_code(401);
    exit;
}
http_response_code(200);
// Now parse $body and fetch the event by its event_id.
require "base64"
require "openssl"

# OpenSSL.secure_compare needs Ruby 3.0 or later.
def verify_signature(body, header, secrets)
  secrets.any? do |secret|
    key = Base64.urlsafe_decode64(secret.delete_prefix("whsec_"))
    expected = OpenSSL::HMAC.hexdigest("SHA256", key, body)
    header.to_s.split(",").any? do |entry|
      scheme, value = entry.strip.split("=", 2)
      scheme == "v1" && !value.nil? && OpenSSL.secure_compare(value, expected)
    end
  end
end

# Sinatra
post "/webhooks/processout" do
  body = request.body.read # raw body: do not parse it first
  halt 401 unless verify_signature(body, request.env["HTTP_PROCESSOUT_SIGNATURE"], SIGNING_SECRETS)
  # Now parse body and fetch the event by its event_id.
  200
end

Check your code with a test vector

Run your verification function on these values. It must return true. These are demo values for testing your code only. Never use this secret as a real signing secret.

InputValue
Secretwhsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
Body{"event_id":"ev_2mNXTMwfzXPPFZrzsMjiw1wPoZ9mGsBC","event_type":"transaction.captured"}
Headerv1=2dad8a6f8903b53809e36e486ed36d5bc4b1beed4d9ac785478ae64023b58967

The body has no spaces and no trailing newline. To check the rotation case, keep the same secret and body and use the two-signature header from Step 2. It must also
return true. It must return false if you change a single character of the body.

If you get a different hex value, check these first:

  • You used the whole whsec_… string as the key. The key is the 32 bytes you get after removingwhsec_ and decoding the rest.
  • You decoded with standard base64 instead of base64url. The secret can contain - and _.
  • Your framework parsed the JSON body and you serialised it again. Use the raw bytes.

Step 4: Rotate the secret

Rotate the secret on a regular schedule, when someone who knew it leaves your team, or at once if you think it has leaked. Rotating also gives you a new secret if you lost the old one.

In the Dashboard, choose Rotate signing secret in the endpoint's action menu.

What happens after a rotation

  • The new secret is active immediately.
  • The old secret stays valid for 30 minutes. Its expires_at shows the exact time.
  • Until then, a delivery can be signed with the old secret only, or with both. After expires_at, deliveries are signed with the new secret only.

So, to rotate without rejecting a single delivery:

  1. Rotate and save the new secret.
  2. Within 30 minutes, add the new secret to your configuration next to the old one. Your
    code now accepts both.
  3. After the old secret's expires_at has passed, remove the old secret.

Do not replace the old secret with the new one in step 2. For a few minutes some deliveries are still signed with the old secret only, and you would reject them.

If you miss the 30 minutes, deliveries signed only with the new secret fail your check and you answer 401. Nothing is lost. They are retried for up to 72 hours, and they go through as soon as you add the new secret.

If the secret has leaked, remove the old secret from your configuration straight away and keep only the new one. Deliveries that are still signed only with the old secret will fail your check or up to 30 minutes and are then retried with the new signature.

Rotation limit

An endpoint can hold at most four secrets: one active and up to three that are expiring. In
practice this means at most three rotations in any 30 minutes. A fourth attempt returns 400 with the error type request.validation.secret-limit-exceeded. Wait until the oldest expiring secret passes its expires_at, then try again.

See which secrets an endpoint has

GET /webhook-endpoints/{id} and the Dashboard list the secrets of an endpoint. You see when each was created and when it expires, but never the secret itself. The active secret has no expires_at.

The ProcessOut-Signature header is reserved

You cannot add a custom header called ProcessOut-Signature (in any letter case) to a webhook endpoint. The request is rejected with 400 and the error type
request.validation.reserved-header-name.

What the signature does not protect

  • Replays. The signature has no timestamp, so a copy of a genuine request stays valid. Someone who captured one could send it to you again. Two habits you should already have make this harmless: deduplicate on the event id, and fetch the event from our API before you act on it.
    A replayed request then only makes you look up an event you already handled. Always use anhttps:// URL for your endpoint, so requests cannot be captured in transit in the first place.
  • Headers and the URL. Only the body is signed. Your custom headers are sent as you configured them, but they are not part of the signature.

Quick reference

ItemValue
HeaderProcessOut-Signature: v1=<hex>[, v1=<hex>…]
AlgorithmHMAC-SHA256, hex-encoded, lowercase
What is signedthe raw request body, byte for byte
Keythe secret without whsec_, base64url-decoded (unpadded), 32 bytes
Valid whenany v1 entry matches any secret you hold
Unknown schemesignore them
Secret shownonce, when it is created or rotated
Old secret after rotationstill valid for 30 minutes, until its expires_at
Secrets per endpointat most 4: 1 active and up to 3 expiring
Not signedwebhooks sent to a per-invoice webhook_url
Rotation endpointPOST /webhook-endpoints/{id}/secret with an Idempotency-Key header
On a failed checkanswer 401. The delivery is retried for up to 72 hours

Frequently asked

My deliveries have no ProcessOut-Signature header. Why?
There are three possible reasons:

  • Signing is not enabled for your project yet. Contact support.
  • The endpoint was created before signing was available and has no secret. Generate one in the Dashboard or call the rotation endpoint.
  • The webhook was sent to a per-invoice webhook_url. These are never signed.

Should I reject a delivery that has no signature?
On a webhook endpoint that already receives signed deliveries, yes. In rare cases a delivery can go out unsigned because of an internal error on our side. If you reject it, it is retried like any other failed delivery.

Do not require a signature on a URL that you also use as a per-invoice webhook_url. Those
deliveries are never signed, so you would reject all of them. Use a separate URL for them, and check them by fetching the event from our API.

I lost my secret. Can you send it to me again?
No. We cannot show a secret a second time. Rotate the endpoint's secret to get a new one.

Can I choose my own secret?
No. Secrets are always generated by ProcessOut.

Does signing change the body or the delivery rules?
No. The body, the retry schedule and the five-second response limit stay the same.


Did this page help you?