SMARTPAY

Signature Verification for Payment API Security in the Philippines

Why a Fake Payment Webhook Is Easier to Send Than You Think

The first time I tried to fake one of our own webhook calls, it took less than ten minutes. I copied the JSON shape from our own documentation, pointed a script at our staging callback URL, and sent a payload claiming an order had been paid in full. No special tooling, no insider access — just a text editor and curl. That exercise is why I now treat signature verification as a non-negotiable line item in every payment integration I review, not an optional hardening step for "later."

Here is the uncomfortable truth about payment webhooks: the URL that receives them is, by definition, public. Anyone who can guess or discover your callback endpoint can send a POST request to it. If your order system reacts to "status: paid" by releasing goods, activating a subscription, or crediting a wallet balance without first confirming the message actually came from your payment gateway, the only thing standing between a stranger and free product is the hope that nobody tries. That is not a security model, and it is exactly the gap signature verification is designed to close.

This article is written from the merchant's side, not the gateway's. I lead technical integration for a China-based team operating in the Philippines, and most of what follows comes from reviewing other teams' integration code — ours included, in its earlier and more embarrassing versions.

The Mechanism: HMAC Signatures Over a Canonical Payload

Signature verification is not cryptography for its own sake. It answers one question: "did this message come from the party who holds the secret, unmodified, in this exact form?" The common mechanism for payment APIs — the one SMARTPAY and most serious gateways use — is HMAC (Hash-based Message Authentication Code) built on a hash function like SHA-256.

Both sides — merchant and gateway — share a secret key that never travels over the network. When one side sends a message (an API request, an API response, or a webhook notification), it arranges the meaningful fields into a fixed, predictable string — the "canonical" form — and runs that string through HMAC-SHA256 with the secret. The output is the signature, sent as a header or field alongside the payload. The receiving side repeats the exact same computation independently. If the two signatures match, the message is authentic and untampered. If they don't, either the secret doesn't match, the payload was altered in transit, or someone without the secret is trying to impersonate the sender.

The canonical string matters more than people expect. Sign a JSON object directly and you hit a subtle problem: key ordering, whitespace, and number formatting can differ between the two ends without either side doing anything wrong, and a signature check fails for reasons that have nothing to do with security. That's why well-designed schemes define an explicit canonical format: take a fixed list of fields, sort them by key name, join them with a consistent delimiter, and sign that string rather than the raw request body. Our API reference covering the signature rules and parameter list lays out exactly which fields participate and in what order — the first thing to check when a mismatch shows up in testing.

Signing an Outbound API Request

When your server calls the gateway's API — to create a collection order, check a transaction status, or trigger a payout — you sign the outbound request so the gateway can confirm it came from your registered merchant account and not from someone who merely guessed your merchant ID. A typical Node.js implementation looks like this:

const crypto = require('crypto');

function buildCanonicalString(params) {
  // Exclude the signature field itself, drop empty values,
  // sort remaining keys alphabetically, join as key=value pairs.
  return Object.keys(params)
    .filter((key) => key !== 'sign' && params[key] !== '' && params[key] != null)
    .sort()
    .map((key) => `${key}=${params[key]}`)
    .join('&');
}

function signRequest(params, secretKey) {
  const canonical = buildCanonicalString(params);
  const signature = crypto
    .createHmac('sha256', secretKey)
    .update(canonical, 'utf8')
    .digest('hex');
  return { ...params, sign: signature };
}

// Example usage when creating a collection order
const orderParams = {
  merid: 'M100293',
  tno: 'ORDER-20261004-00981',
  amount: ORDER_AMOUNT, // numeric value from your own order record
  notify_url: 'https://merchant.example.com/webhooks/payment',
  timestamp: Math.floor(Date.now() / 1000),
};

const signedParams = signRequest(orderParams, process.env.SMARTPAY_SECRET_KEY);
// signedParams.sign is now attached and ready to send with the request

Two details beyond the signing logic matter here: the secret key comes from an environment variable, never hardcoded, and a timestamp is one of the signed fields — both covered later, under secret handling and replay protection.

Verifying an Inbound Webhook Signature

Verification mirrors signing, with one critical addition: how you compare the two signature strings — the step most teams get subtly wrong. When the gateway sends a webhook notification — "this order is now paid," "this payout has settled," "this transaction was reversed" — your endpoint recomputes the expected signature from the received payload using the same canonical-string logic and shared secret, then compares it against the signature the gateway sent. If they match, the notification is genuine and safe to act on. If they don't, it should be rejected outright — not logged as a warning and processed anyway "just this once."

const crypto = require('crypto');
const express = require('express');
const app = express();

app.use(express.urlencoded({ extended: true }));

function buildCanonicalString(params) {
  return Object.keys(params)
    .filter((key) => key !== 'sign' && params[key] !== '' && params[key] != null)
    .sort()
    .map((key) => `${key}=${params[key]}`)
    .join('&');
}

function isValidSignature(receivedParams, secretKey) {
  const { sign: receivedSign, ...rest } = receivedParams;
  if (!receivedSign) return false;

  const canonical = buildCanonicalString(rest);
  const expectedSign = crypto
    .createHmac('sha256', secretKey)
    .update(canonical, 'utf8')
    .digest('hex');

  // Constant-time comparison: both buffers must be the SAME LENGTH
  // before timingSafeEqual can be called, or it throws.
  const receivedBuf = Buffer.from(receivedSign, 'hex');
  const expectedBuf = Buffer.from(expectedSign, 'hex');

  if (receivedBuf.length !== expectedBuf.length) return false;
  return crypto.timingSafeEqual(receivedBuf, expectedBuf);
}

app.post('/webhooks/payment', (req, res) => {
  const secretKey = process.env.SMARTPAY_SECRET_KEY;

  if (!isValidSignature(req.body, secretKey)) {
    // Reject first. Do not touch order state. Do not continue processing.
    return res.status(401).send('invalid signature');
  }

  // Only now is it safe to read req.body.tno / req.body.status
  // and update the order in your own database.
  processVerifiedNotification(req.body);
  res.status(200).send('ok');
});

The line doing the real security work is crypto.timingSafeEqual. A naive comparison like receivedSign === expectedSign, or a manual loop that exits as soon as it hits a mismatch, takes measurably less time to reject a signature that differs in its first character than one that differs in its last. That timing gap is small, but over enough repeated requests it gives an attacker a byte-by-byte oracle for reconstructing a valid signature without ever knowing the secret. A constant-time comparison function takes the same amount of time regardless of where the mismatch occurs, removing that side channel. Python developers get the same behavior from hmac.compare_digest().

Common Mistakes We See in Merchant Integrations

Reviewing enough integrations — some our own, from earlier iterations — the failure modes cluster into a small, repeatable set. None are exotic. All are easy to introduce under deadline pressure, and easy to miss in a review that only checks the "happy path."

Common MistakeCorrect Approach
No signature check on webhooks at all — the endpoint trusts any POST request that matches the expected JSON shape. Every inbound webhook is verified against a recomputed signature before any field in the payload is treated as true.
Verification runs, fails, and the code logs a warning but processes the order anyway "to avoid losing a legitimate payment." On verification failure, the request is rejected with an error response and the order state is left untouched. A legitimate notification will be retried by the gateway; a forged one should never succeed.
Signatures are compared with ==, .equals(), or a hand-written loop that exits early on the first mismatched character. Signatures are compared with a constant-time function (crypto.timingSafeEqual, hmac.compare_digest) so comparison time does not leak information about where the mismatch is.
The canonical string is built inconsistently — sometimes from the raw request body, sometimes from a re-parsed and re-ordered object — so legitimate requests intermittently fail verification. One documented, deterministic canonical-string function is shared across every endpoint and tested with fixed sample payloads.
The webhook handler trusts the sender's IP address instead of, or in addition to relying too heavily on, the signature. IP allowlisting can be a defense-in-depth layer, but the signature check is the actual authentication mechanism and must stand on its own.
Debug logs print the full raw signature and sometimes the secret key itself when a mismatch occurs. Logs record only that verification failed, the order reference, and a truncated or masked signature — never the secret, and never enough of the signature to be useful to an attacker reading the logs.
A webhook with a valid signature from days earlier can be replayed against the endpoint with no consequence. A timestamp or nonce is included in the signed fields and checked against an acceptable window, so an old — even validly signed — notification is rejected as stale.

Our own rundown of common signature-related error codes is a useful companion to this table if you're mid-integration and trying to work out why a specific request is being rejected rather than why your architecture allows a rejection to be ignored.

Secret Management, Rotation, and Replay Protection

Signature verification is only as strong as the secret behind it. Store the secret as an environment variable or in a secrets manager, never in source code and never in a config file committed to a shared repository. I've personally had to clean up after a secret that sat in a public-facing frontend bundle for weeks because someone copied a server-side signing snippet into a client-side helper without thinking about where that code would ship. Treat it with the same discipline as a database password.

Rotate the secret on a regular schedule, and immediately after any suspected exposure — a departing contractor, a leaked log file, a repository that was briefly public. Most gateways, including ours, support issuing a new secret while keeping the old one active in parallel, so you can accept both during a transition window and retire the old one once the new one is confirmed live everywhere.

Build in replay protection deliberately rather than assuming signing alone covers it. A signature confirms the message came from the secret holder and wasn't altered — it says nothing about whether this is the first time it's been sent or the fifth. Including a timestamp in the signed canonical string and rejecting anything outside a reasonable window closes that gap. Pair it with idempotency on your side — recognizing an order reference already marked paid and declining to process it twice — and a replayed notification has two independent reasons it can't cause harm. Our notes on payment API error handling practices cover idempotency patterns in more depth.

It's worth tying signature verification into reconciliation rather than treating it as a one-time gate at the door. A notification that passes verification should still be cross-checked against an independent status-query call for anything above your normal risk tolerance. Our walkthrough on how webhook notifications and verification feed into reconciliation covers what to do when a verified webhook and a status query briefly disagree.

What a Licensed Gateway Should Give You

None of this works if the gateway on the other end treats its own signature scheme as an afterthought — undocumented fields, inconsistent canonicalization between sandbox and production, or a secret-issuance process loose enough that anyone could request a merchant's key through a support ticket. Part of why we evaluate a gateway's compliance posture before integrating is that a properly licensed, regulated operator has usually gone through the same scrutiny on its security architecture as on its financial controls.

SMARTPAY operates under BSP (Bangko Sentral ng Pilipinas) licensing as an OPS (Operator of Payment System) and EMI (Electronic Money Issuer), which means our signature scheme, key issuance process, and webhook infrastructure fall under regulatory review — not just an engineering decision made in isolation.

If you're evaluating a gateway and trying to separate a serious operator from one cutting corners, our explainer on what the BSP OPS and EMI licenses mean and how to verify them is a good starting point before you get near signing code. A gateway that takes licensing seriously will also hand you clear, versioned signature documentation, separate sandbox and production secrets, and a straightforward key-rotation process — sloppy security hygiene and sloppy compliance hygiene tend to come from the same place.

Merchants ask this often during onboarding: the BSP OPS/EMI framework is specifically what distinguishes a regulated payment operator from an unlicensed intermediary, and signature verification infrastructure is one of the concrete, inspectable things that framework touches.

Frequently Asked Questions

Is HMAC-SHA256 enough, or do I need asymmetric signing?

For most merchant integrations, HMAC with a shared secret and SHA-256 is sufficient and is what most payment gateways standardize on. Asymmetric signing (public/private key pairs) matters more in multi-party or marketplace setups where several independent systems need to verify a signature without all holding the same key. For a direct merchant-to-gateway relationship, HMAC is the right level of complexity.

What should my server do when signature verification fails?

Reject the request. Respond with an error status, do not update any order or transaction state from the payload, and log enough detail to investigate — timestamp, order reference, source IP — without logging the secret or the full signature. A failed verification should never be treated as "probably fine, process it anyway."

Does HTTPS already make signature verification unnecessary?

No. TLS protects data in transit; it stops someone from reading or tampering with traffic as it crosses the network. It does nothing to confirm who sent the request. Anyone can open a TLS connection to your public webhook URL and send a perfectly encrypted, completely fake payload. Signature verification answers a different question than TLS does, and you need both.

How often should I rotate the shared signing secret?

There's no single interval right for every merchant, but a sensible baseline is a regular rotation schedule, plus immediate rotation after any event that could have exposed the secret — a departing engineer with repository access, a secret logged or committed by mistake, a third-party tool that held the key. Build your code to accept two valid secrets during a transition window so rotation never risks an outage.

Can I trust a webhook just because it came from the gateway's known IP range?

IP allowlisting is a reasonable defense-in-depth layer, but it should never replace signature verification. IP ranges can change, can be shared across cloud infrastructure, and in some setups can be spoofed or proxied. The signature is the actual authentication mechanism; the IP check is, at best, a secondary filter.

What's the practical difference between signing a request and verifying one?

Same underlying mechanism — build a canonical string, run HMAC with the shared secret — but the direction differs. Signing happens on the sending side, proving "this message came from me." Verifying happens on the receiving side, confirming "this message really came from who it claims." A merchant does both: signs requests sent to the gateway's API, and verifies webhooks received from it.

How do I protect against replay of an old, validly-signed webhook?

Include a timestamp among the signed fields and reject anything received outside an acceptable window. Combine that with idempotency on your side — track order references you've already processed and decline to act twice, even if the signature checks out. Neither control alone is airtight; together they close the gap a signature by itself leaves open.

Should I log the raw signature when verification fails, for debugging?

Log that a failure occurred, along with the order reference, timestamp, and enough metadata to reproduce the issue — but mask or truncate the signature itself and never log the secret key. A full signature sitting in a log file that later leaks gives an attacker material they shouldn't have, for a convenience you can get almost as easily from masked values plus a local test reproduction.

Signature verification costs almost nothing to do correctly — a few dozen lines of tested code — while skipping it leaves a door open that takes no real skill to walk through. One habit worth keeping: verify first, with a constant-time comparison, and only then touch your order state.

If you're integrating with SMARTPAY and want a second set of eyes on your signing or verification code, reach out directly.

Telegram: @LouisSMARTPAY · Email: [email protected]

相关推荐