Verifying your webhooks

Ensure the webhooks you receive are valid and are from inai

How to verify your webhooks?

Each webhook call from inai includes three headers with additional information used for verification:

  • webhook-id: the unique message identifier for the webhook message. This identifier is unique across all messages, but will be the same if the same webhook is resent (e.g. due to a previous failure).
  • webhook-timestamp: timestamp in seconds since epoch.
  • webhook-signature: the Base64 encoded list of signatures (space delimited).

You can find the the secret key in the Endpoints tab under the Signing Secret section. See screenshot below for an example.

Screenshot on your inai dashboard showing your webhook signing secret.  In the screenshot above, the signing secret is at bottom right.Screenshot on your inai dashboard showing your webhook signing secret.  In the screenshot above, the signing secret is at bottom right.

Screenshot on your inai dashboard showing your webhook signing secret. In the screenshot above, the signing secret is at bottom right.

📘

Why verify webhooks?

Your webhook endpoint accepts HTTP POST requests but what's to stop an attacker from sending fake webhook events to that endpoint? You need to be sure your webhook events are from inai and no one else.

To prevent such attacks, all webhook payloads are signed by a secret key unique to each endpoint you configure. The signature confirms the authenticity of the source.

Constructing the data to be signed

data_to_sign = f"{webhook_id}.{webhook_timestamp}.{body}"

Where webhook_id and webhook_timestamp are discussed in the section above and body is the raw body of the request. Make sure that body is not modified in any way before verifying the webhook.

Compute the signature

For computing the expected signature, HMAC the data_to_sign from the above section with SHA-256 algorithm using the part after the whsec_ in the signing secret you obtained from the dashboard. For example, given the secret whsec_Qu4K/4SPdeSOLdMUziluUNUmGBi74SWq you will want to use Qu4K/4SPdeSOLdMUziluUNUmGBi74SWq.

The generated signature should match the one sent the webhook-signature header.

The webhook-signature header is composed of a list of space delimited signatures and their corresponding version identifiers. The signature list is most commonly of length one. Though there could be any number of signatures. For example:

v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo=
Same data shown on multiple lines for readability

v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= \
v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= \
v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo=

Code sample

The following code samples illustrate how to validate the signature of any received webhooks.

import base64
import hashlib
import hmac

timestamp = "1643274715"  #  from header - "webhook-timestamp"
expected_signatures = (  #  from the header - "webhook-signature"
    "v1,BPa1NBhZ6fZij2JNoklPYa5I8rftInNVTSwNe214ND4= "
    "v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= "
    "v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo="
).split(" ")
expected_signatures = [  # ignore the version in signature
    es[3:] for es in expected_signatures
]
msg_id = "msg_24H5gh1nqFftssfDSd2NheUZ12a"  # from the header - "webhook-id"

secret = "aDKFVPZRgVWB/tDAfUpEHuHmNNdjy7Fa"  # from the inai dashboard

data = '{"transaction": {"transaction_id": "c873fc39-17c3-421b-852b-71f78672e8e0", "amount": "56", "type": "CHARGE", "currency": "USD", "status": "FAILED", "rail": "CREDIT_CARD", "provider": {"alias": "Test Paymentez", "name": "Paymentez"}, "transaction_time": "2022-01-27T09:12:54+00:00", "customer": {"id": "e5c19def-a01e-4b75-bb5f-470a3944620d", "external_id": null, "email": "[email protected]", "phone": null}, "order_id": "869e3332-b023-429c-a7cf-d8708c9139b3", "subscription_id": null, "order_type": "REGULAR", "error_code": "processing_error"}, "event_type": "transaction.failed"}'

signature = base64.b64encode(
    hmac.new(
        base64.b64decode(secret),
        f"{msg_id}.{timestamp}.{data}".encode(),
        hashlib.sha256,
    ).digest()
).decode("utf-8")

assert signature in expected_signatures
const crypto = require('crypto');

const sigHashAlg = "sha256";

//  from the header - "webhook-timestamp"
var timestamp = "1643274715";

//  from the header - "webhook-signature"
var expected_signatures = (
        "v1,BPa1NBhZ6fZij2JNoklPYa5I8rftInNVTSwNe214ND4="
        + " v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo="
        + " v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo="
    ).split(" "
    ).map(es => es.slice(3));

// from the inai dashboard
var secret = "aDKFVPZRgVWB/tDAfUpEHuHmNNdjy7Fa";

// from the header - "webhook-id"
var msg_id = "msg_24H5gh1nqFftssfDSd2NheUZ12a";

// raw request body in string
var data = '{"transaction": {"transaction_id": "c873fc39-17c3-421b-852b-71f78672e8e0", "amount": "56", "type": "CHARGE", "currency": "USD", "status": "FAILED", "rail": "CREDIT_CARD", "provider": {"alias": "Test Paymentez", "name": "Paymentez"}, "transaction_time": "2022-01-27T09:12:54+00:00", "customer": {"id": "e5c19def-a01e-4b75-bb5f-470a3944620d", "external_id": null, "email": "[email protected]", "phone": null}, "order_id": "869e3332-b023-429c-a7cf-d8708c9139b3", "subscription_id": null, "order_type": "REGULAR", "error_code": "processing_error"}, "event_type": "transaction.failed"}';

const signature = crypto.createHmac(
        sigHashAlg,
        new Buffer(secret, 'base64')
    ).update(`${msg_id}.${timestamp}.${data}`, 'utf8'
    ).digest('base64');

console.log(expected_signatures.includes(signature));

Verify Timestamp

There are certain cases where a payment might be marked as successful initially and then fail later. If a malicious actor is able to intercept the success callback and then replay it post a failure callback, it will still have a valid signature and your system may treat it as a successful payment. To prevent this from happening, use the webhook-timestamp header to check if the event timestamp is within a certain seconds (eg. +/-300seconds) from server timestamp.

It would also help to check if the new event always has a timestamp greater than the last event received.

📘

Note on syncing clocks

Make sure the server processing the webhooks is synchronized with a NTP server so that you don't accidentally reject events because of a bad clock.