Configure webhooks for your integration

Learn about the structure of Payments API webhooks, how to sign your requests to ensure they're valid, and the retry logic for webhooks.

Add your webhook URI in Console

In the Payments API v3, you receive webhooks to a given client_id and the associated app. Each of your apps in Console can have a single webhook URI, which the Payments API sends webhooks to.

To specify a Webhook URI for an app:

  1. Open the app that you want to specify a webhook URI for.
  2. In the left menu, select Payments, and then Settings.
  3. Under the Webhook URI heading, select the pencil icon and enter your URI.
    Each app can only have one webhook URI at a given time.
The **Payments > Settings** page, where you upload your public key and set your webhook URI.

Validate the signature of received webhooks

All incoming webhook requests must be validated through the Tl-Signature that accompanies the webhook. If you don't validate the signature, you risk accepting fraudulent payment status events.

We strongly recommend you use our signing libraries to verify the Tl-Signature of any webhooks you receive. We have libraries available for the following laguages:

One thing that all signing libraries require is a pair of public and private keys. You upload the public key to Console, which enables you to get your kid. You use this kid as well as the private key you generated with all of our signing libraries.

To learn how to generate your public and private keys, see our request signing guide for Payments API v3 requests.

Once you have your kid and private key, you can use them to generate a Tl-Signature, and use that in a function to validate the headers in your webhooks.

🚧

Be careful of serialisation

When you provide your private key when you sign your webhooks, you need to ensure it is exactly the same, byte for byte. As such, if you use any libraries, you should ensure they handle serialisation consistently, otherwise you might receive errors when validating your requests.

Learn more about common signing mistakes ands our recommendations.

Tl-Signature generation

The code block below contains how the Tl-Signature is generated in each library:

using TrueLayer.Signing;

// `Tl-Signature` value to send with the request.
var tlSignature = Signer.SignWithPem(kid, privateKey)
    .Method("POST")
    .Path(path)
    .Header("Idempotency-Key", idempotency_key)
    .Body(body)
    .Sign();
// `Tl-Signature` value to send with the request.
signature, err := tlsigning.SignWithPem(Kid, privateKeyBytes).
        Method("POST").
        Path("/payouts").
        Header("Idempotency-Key", idempotencyKey).
        Body(body).
        Sign()
// `Tl-Signature` value to send with the request.
Signer.from(kid, privateKey)
        .header("Idempotency-Key", idempotencyKey)
        .method("post")
        .path(path)
        .body(body)
        .sign();
const tlSigning = require('truelayer-signing');

// `Tl-Signature` value to send with the request.
const signature = tlSigning.sign({
  kid,
  privateKeyPem,
  method: "POST",
  path: "/payouts",
  headers: { "Idempotency-Key": idempotencyKey },
  body,
});
tl_signature = sign_with_pem(KID, PRIVATE_KEY) \
    .set_method(HttpMethod.POST) \
    .set_path(path) \
    .add_header("Idempotency-Key", idempotency_key) \
    .set_body(body) \
    .sign()
# `Tl-Signature` header value to send with the request
tl_signature = TrueLayerSigning.sign_with_pem
  .set_method("POST")
  .set_path(path)
  .add_header("Idempotency-Key", idempotency_key)
  .set_body(body)
  .sign
// `Tl-Signature` value to send with the request.
let tl_signature = truelayer_signing::sign_with_pem(kid, private_key)
    .method(Method::Post)
    .path("/payouts")
    .header("Idempotency-Key", idempotency_key)
    .body(body)
    .build_signer()
    .sign()?;

Validation functions

The code block below contains the functions for signature validation in each library:

// `jku` field is included in webhook signatures
var jku = Verifier.ExtractJku(webhookSignature);

// check `jku` is an allowed TrueLayer url & fetch jwks JSON (not provided by this lib)
EnsureJkuAllowed(jku);
var jwks = FetchJwks(jku);

// jwks may be used directly to verify a signature
// a SignatureException is thrown is verification fails
Verifier.VerifyWithJwks(jwks)
    .Method("POST")
    .Path(path)
    .Headers(allWebhookHeaders)
    .Body(body)
    .Verify(webhookSignature);
// `jku` field is included in webhook signatures
jwsHeader, err := tlsigning.ExtractJwsHeader(webhookSignature)
if err != nil {
  // Handle error
}

// check `jku` is an allowed TrueLayer url & fetch jwks JSON (not provided by this lib)
if !jkuAllowed(jwsHeader.Jku) {
  // Handle error
}
jwks := fetchJwks(jwsHeader.Jku)

// jwks may be used directly to verify a signature
err = tlsigning.VerifyWithJwks(jwks).
        Method("POST").
        Path(path).
        Headers(allWebhookHeaders).
        Body(body).
        Verify(webhookSignature)
// `jku` field is included in webhook signatures
String jku = Verifier.extractJku(webhookSignature);

// check `jku` is an allowed TrueLayer url & fetch jwks JSON (not provided by this lib)
ensureJkuAllowed(jku);
String jwks = fetchJwks(jku);

Verifier.verifyWithJwks(jwks)
        .method("POST")
        .path(path)
        .headers(allWebhookHeaders)
        .body(body)
        .verify(webhookSignature);
const tlSigning = require('truelayer-signing');

// `jku` field is included in webhook signatures
let jku = tlSigning.extractJku(webhookSignature);

// check `jku` is an allowed TrueLayer url & fetch jwks JSON (not provided by this lib)
ensureJkuAllowed(jku);
let jwks = fetchJwks(jku);

// jwks may be used directly to verify a signature
// a SignatureError is thrown is verification fails
tlSigning.verify({
  jwks,
  signature: webhookSignature,
  method: "post",
  path,
  body,
  headers: allWebhookHeaders,
});
# `jku` field is included in webhook signatures
jws_header = extract_jws_header(webhook_signature).jku

# check `jku` is an allowed TrueLayer url & fetch jwks JSON (not provided by this lib)
ensure_jku_allowed(jku)
jwks = fetch_jwks(jku)

# jwks may be used directly to verify a signature
verify_with_jwks(jwks, jws_header) \
    .set_method(HttpMethod.POST) \
    .set_path(path) \
    .add_headers(headers) \
    .set_body(body) \
    .verify(tl_signature)
# The `jku` field is included in webhook signatures
jku = TrueLayerSigning.extract_jws_header(webhook_signature).jku

# You should check that the `jku` is a valid TrueLayer URL (not provided by this library)
ensure_jku_allowed(jku)

# Then fetch JSON Web Key Set from the public URL (not provided by this library)
jwks = fetch_jwks(jku)

# The raw JWKS value may be used directly to verify a signature
TrueLayerSigning.verify_with_jwks(jwks)
  .set_method(method)
  .set_path(path)
  .set_headers(headers)
  .set_body(body)
  .verify(tl_signature)
The verify_with_jwks function may be used to verify webhook Tl-Signature header signatures.

// `jku` field is included in webhook signatures
let jku = truelayer_signing::extract_jws_header(webhook_signature)?.jku?;

// check `jku` is an allowed TrueLayer url & fetch jwks JSON (not provided by this lib)
ensure_jku_allowed(jku)?;
let jwks = fetch_jwks(jku);

// jwks may be used directly to verify a signature
truelayer_signing::verify_with_jwks(jwks)
    .method(Method::Post)
    .path(path)
    .headers(all_webhook_headers)
    .body(body)
    .build_verifier()
    .verify(webhook_signature)?;

Verify the signature manually

Although we strongly recommend using our libraries, you can manually verify webhooks you receive. For a full explanation of our signing requirements, see our request signing docs on Github.

Recommendations for validation and signing

When you set up request signing, make sure to check for these common issues.

Endpoint paths

Whichever library you use, ensure that the path you sign is the same as the path used to send the request. For example, if you're testing the endpoint, the path must be https://api.truelayer-sandbox.com/test-signature.

if you are doing a payment creation call, then the path value is /v3/payments.

Format issues

All signed headers sent with a request must be exactly as they were signed, with none missing. This is also the case for the Idempotency-Key header, which you must also send with exactly the same value both when generating the signature, and when using it in the payment request.

As such, you must ensure that the body passed to the signing library matches the body sent with the request exactly, byte for byte:

  • It cannot be formatted differently or have fields in a different order.
  • The request body must have no trailing newlines if it was not signed that way.
  • If you have some fields that you passed as null (which we do not recommend as you can just not pass the field), those fields could be removed if the body is serialised, which causes a difference in the bodies.

For these reasons, we strongly recommend that you first serialise the body and store it in a 'body' object. This is then the exact same object you use both to generate the signature, and when you pass the body in the request call.

You should also consider and test special characters in your signing solution. The body object you pass for the signature creation, must be passed in an identical form when you send it in the request body. This means you must not apply anything that changes the body compared to how it was when you sent it in the request. This could happen if unicode accents are altered, or even if non-visible aspects such as encoding or blank spaces/new lines added.

Keys and kid

Ensure the public key and private key you are signing with match the KID (Public Key ID) in our Console. You can find your KID in Payments > Settings.

Webhook retry policy

We consider a webhook successfully delivered when we receive a 2XX success status code from your webhook URI.

If we receive any other status code (for instance, if your API is temporarily unavailable), The Payments API starts retrying the webhook. The retry policy for the Payments API is jittered exponential backoff.

Jittered exponential backoff means that the API starts with some fast retries, and then waits increasingly longer. We will immediately perform some fast retries and then start waiting increasingly longer. The API retries a webhook for up to 72 hours. If it receives any other status codes than 2xx after retrying for 72 hours, it discards the webhook.

We apply this retry policy for all Payments API v3 webhook categories, including payment, payment link, refund, payout, mandate, and merchant account webhooks.