Salta al contenuto

How to verify Payora signed webhooks in PHP

Learn how to verify Payora signed webhooks in PHP by capturing the raw payload, recreating the signature, and comparing it safely.

Payora11 min readEN · RU · UK · ES · DE
How to verify Payora signed webhooks in PHP

What Payora signed webhooks are

Payora signed webhooks are HTTP requests that carry a digital signature, so your PHP endpoint can check that the event really came from Payora and was not altered on the way. If you are learning how to verify Payora signed webhooks in PHP, the basic flow is simple: Payora sends an event, your server reads the raw payload, and your code verifies the signature before it trusts anything inside the body.

That check matters because a webhook can arrive from any client that knows your URL. A bad actor can copy the endpoint from logs, then POST a fake payment event with a tidy JSON body. A signature stops that trick.

The event itself is only useful after verification. Before that, it is just text.

Think of it like a numbered ticket at a door. The body is the message, the signature is the stamp, and your PHP code decides whether both parts match. If they do, you process the event. If they do not, you reject it fast.

If you already run a payment flow on the site, the same discipline shows up in other parts of the stack too. A merchant who reads a crypto payment gateway for ecommerce guide will see the same pattern: accept less, verify more, and only then update the order.

Prerequisites for a PHP webhook endpoint

You do not need a giant framework for this. A PHP 8.x endpoint is enough, and PHP 7.4 can work for some projects, but the code should have access to the raw request body and to the headers that Payora sends. That means your server setup must allow normal HTTP request access, not a custom layer that rewrites or drops headers.

Use HTTPS. Without TLS, the webhook travels in plain sight, and a signature alone does not fix weak transport security. A certificate, correct virtual host config, and a reachable public URL are the baseline.

Two PHP extensions matter in practice: hash for HMAC work and, if you build JSON handling around it, the core JSON functions that ship with PHP. You may also want cURL for testing from your side, but the webhook endpoint itself does not need it.

You also need the Payora secret or signing key that matches the webhook configuration in your Payora account. Keep that value outside the web root. Keep it out of git. A secret in a public repo is a fast way to turn verification into theater.

One more thing: your endpoint should accept POST requests only. GET requests can return 405, and that small restriction saves time during troubleshooting because it narrows the traffic you actually inspect.

Capture the raw webhook payload and headers

Signature checks depend on the exact bytes Payora sent. In PHP, read the raw request body with file_get_contents('php://input'). Do not rebuild the payload from $_POST or from decoded JSON, because either step can change spacing, key order, or encoding.

The same rule applies to headers. You need the signature header. Use getallheaders() where available, or read server variables like $_SERVER['HTTP_X_...'] if your environment strips the header helper. Small hosting differences matter here.

Imagine a webhook body with 412 bytes. If your code decodes it and re-encodes it, you may still see the same fields, but the bytes are no longer identical. That one mismatch is enough to break verification.

Here is the habit to keep: capture first, parse second. First store the raw string. Then inspect headers. Only after that should you touch the payload structure.

If you test payment flows before launch, the same habit saves time elsewhere. The article on how to test a crypto payment applies the same logic to sandbox events, where payload fidelity is the thing that keeps your test useful.

Recreate the expected signature in PHP

The usual pattern is an HMAC computed from the raw payload and a shared secret. Start with the algorithm named in Payora’s webhook docs. If the docs say SHA-256, use SHA-256. If they name a prefix, version tag, or timestamp format, include that exact structure.

A common shape looks like this: build the signed message from the timestamp and raw body, then run hash_hmac() with the shared secret and the named hash algorithm. The result may need hex encoding or base64 encoding depending on the specification. Do not guess the format. Read it from the webhook settings.

Here is a minimal example in PHP style, with placeholders where Payora’s format may differ:

$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_PAYORA_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_PAYORA_SIGNATURE'] ?? '';
$secret = getenv('PAYORA_WEBHOOK_SECRET');

$signedPayload = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signedPayload, $secret);

That example is only a shape, not a promise. If Payora signs the payload alone, drop the timestamp from the string. If Payora signs a canonical JSON form, follow that exact method, not your preferred one. One wrong delimiter will fail every request.

For merchants who also care about billing rules and edge cases, the post on crypto payment invoice limits is a useful companion, because webhook handling and invoice policy often meet in the same order lifecycle.

Compare the computed signature with Payora’s signature

Once you have the expected signature, compare it with the incoming signature using a constant-time comparison. In PHP, that means hash_equals() in most cases. Simple == or === checks are not the right tool here, because they can leak timing hints and they are less defensive about subtle mismatch cases.

Before the comparison, normalize the format exactly as Payora expects. If the header is hex, compare hex. If the header is base64, compare base64. If the incoming signature has a prefix like sha256=, remove or include that prefix exactly as the docs say.

Example flow:

  1. Read the raw body.
  2. Read the signature header.
  3. Build the expected signature from the documented formula.
  4. Compare both strings with hash_equals().
  5. Reject the request with 401 or 400 if they do not match.

A 401 makes sense when the signature is wrong. A 400 makes sense when required headers are missing. Either way, the response should be short. No extra clues.

One sentence is enough here: do not print the secret. That mistake is rare, and it is catastrophic.

Handle timestamp, replay protection, and tolerance rules

If Payora includes a timestamp or nonce, check it before you accept the event. This blocks replay attacks, where an attacker re-sends a valid webhook later and tries to trick your app into paying twice or shipping twice. The timestamp window should follow Payora’s rule, not your guess.

The common pattern is to compare the webhook timestamp to your server time and reject stale requests outside the tolerance window. If the docs say 5 minutes, use 5 minutes. If they say 300 seconds, store 300 seconds as a named setting and avoid hardcoding a mystery number in three files.

Clock drift can break good traffic. A server that is 7 minutes off can reject a valid request. Sync your servers with NTP and keep the timezone set consistently. Timezone does not change Unix timestamps, but bad local settings can still make logs harder to read.

Nonces are even simpler when they exist. Store each nonce once, mark it as used, and refuse a duplicate. A small cache or database table can do the job. One nonce, one acceptance.

If you also accept fixed pricing on payment pages, this is where policy matters. A separate post about should i use fixed price can help you align exchange-rate rules with webhook timing so you do not confirm the wrong amount after a late event.

Process valid events and return the right HTTP response

After verification passes, parse the payload and branch on the event type. A payment.succeeded event should not follow the same code path as a refund.updated event. Separate them cleanly. That way, one handler does one job, and your logs stay readable.

Business logic should run before you send the 2xx response. If you mark an order as paid, write the database row first. If you queue an email, enqueue it first. If either step fails, return a 500 and let Payora retry later.

This order matters because a 2xx response usually tells Payora the event was accepted. If you return 200 too early, then crash on a database write, you may create a mismatch between Payora and your app. That kind of bug is expensive at 2 a.m.

A small response body is enough. “OK” works. Better yet, return no body at all and keep the status code clear. Do not echo the payload back.

For teams that also invoice clients from payment events, the crypto payment gateway for freelancers article shows how a webhook can move from a technical event to a real billing action without adding unnecessary steps.

Troubleshooting failed signature checks

Most failures come from five places: the raw body changed, the header name is wrong, the secret is wrong, the encoding differs, or the timestamp check is outside tolerance. Start with the body. If you logged the raw payload and the computed signature, compare those before touching the database.

Body mutation is common. Framework middleware can trim whitespace, decode JSON, or normalize line endings. One extra newline is enough. If Payora signed {"a":1} and your app reads {"a":1}\n, the HMAC will not match.

Header mismatch is next. Some servers convert dashes to underscores. Some proxy layers remove custom headers unless they are explicitly allowed. If your signature header is missing in PHP, inspect the web server config and the proxy settings, not just the code.

Encoding issues are sneaky. UTF-8 and escaped Unicode can look fine in logs while still producing different bytes. If your application rewrites JSON, the signature may break even though the payload appears identical in a browser.

Wrong secret is the simplest failure and the one people check last. Verify the environment variable, the staging secret, and the production secret separately. A copied secret from the wrong account can waste an afternoon.

Clock drift creates a different symptom: the signature matches, but the request is still rejected because the timestamp is too old. If that happens, check NTP, container clocks, and any reverse proxy that stamps its own time. Small infrastructure drift can look like a bad signature.

If your installation also ties into WordPress or membership billing, the article on how to accept USDT payments is useful because it shows how webhook handling, access control, and payment confirmation fit together in one flow.

One final test helps a lot: send the same payload twice. The first request should pass. The second should fail if you store and reject a reused nonce, or it should pass only if your webhook design does not use nonce-based replay defense. That one duplicate tells you whether your protection really works.

Comments

Ready to get started?

Create an account and have your first invoice running in under an hour.

Cosa risponde questa pagina