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.

Payments API signing libraries

We provide backend libraries and signing libraries in a variety of programming languages to simplify the integration process.

To make this process easier, we recommend using one of the client libraries, which include functionality for signing requests and webhooks. Request signing and validating webhook signatures are the most complex and time-consuming parts of integration for the Payments API. Client libraries help simplify this so you can get up and running faster. Alternatively, if you want to use your own code, you can also implement one of our signing libraries just for this purpose.

To explore our client libraries and signing libraries, visit the TrueLayer Github. There are also example requests in the procedure below.

If you're not using the client libraries, you can also sign requests manually, but we don't recommend using this method.

Add your webhook URI in Console

In the Payments API v3, you receive webhooks to your 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 section of the Settings tab in Console, with example Webhook URI.

The Payments settings section of the Settings tab in Console, with example Webhook URI.

Validate webhook signatures

You must validate all incoming webhook requests 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 languages:


🚧

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 and our recommendations.

To validate webhooks, fetch TrueLayer's public key from the JKU, then pass that key into the verifying function in the signing libraries.

Tl-Signature verification

The code block below demonstrates how to verify the Tl-Signature for each library:

// `Tl-Signature` value to send with the request.
Signer.from(kid, privateKey)
        .header("Idempotency-Key", idempotencyKey)
        .method("post")
        .path(path)
        .body(body)
        .sign();
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();
// This example uses the Guzzle library
use TrueLayer\Signing\Verifier;
use GuzzleHttp\Client;

// Add error handling as appropriate
$httpClient = new Client();
$response = $httpClient->get('https://webhooks.truelayer-sandbox.com/.well-known/jwks')->getBody()->getContents();
$keys = json_decode($response, true)['keys'];

$verifier = Verifier::verifyWithJsonKeys(...$keys); // Use the spread operator.
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()?;
// `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()

Validation functions

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

// `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);
// `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 if verification fails
Verifier.VerifyWithJwks(jwks)
    .Method("POST")
    .Path(path)
    .Headers(allWebhookHeaders)
    .Body(body)
    .Verify(webhookSignature);
$verifier
    ->path('/path')
    ->headers($headers) // All headers you receive. Header names can be in any casing.
    ->body('stringified request body'); // For example file_get_contents('php://input');

try {
    $verifier->verify($headers['tl-signature']);
} catch (InvalidSignatureException $e) {
    throw $e; // Handle invalid signature. You should not use this request's data.
}
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)?;
// `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)

These are the allowed jkus for TrueLayer webhooks:

In path, pass your own webhook URI. For example, if your webhook URI configured in Console is https://client-website.com/webhooks/sub-path/abc_def, the path is /webhooks/sub-path/abc_def.

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 of the URL that you set in the console under Webhook URI.

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 you pass the headers, except the Tl-Signature header, exactly as you receive them. This means that:

  • 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.

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 webhook. This could happen if unicode accents are altered, or even if non-visible aspects such as encoding or blank spaces/new lines added.

So basically you should pass the body exactly as they receive it from TrueLayer. Also, in regards to special characters, you shouldn't try to encode/decode any characters in any way before calling verify.

Therefore, an example of the "raw" webhook content would be (taken from an executedpayment in Sandbox):

{"type":"payment_executed","event_version":1,"event_id":"ac40fe2d-d4c5-458a-ad25-a36c1a502cd6","payment_id":"889b0378-bcdf-4f35-b778-0725981a7ee1","payment_method":{"type":"bank_transfer","provider_id":"mock-payments-gb-redirect","scheme_id":"faster_payments_service"},"executed_at":"2024-08-21T12:27:36.586Z","payment_source":{"account_identifiers":[{"type":"sort_code_account_number","sort_code":"040668","account_number":"00000871"}],"account_holder_name":"J \\u0026\\u002B SANDBRIDGE JR."}}

In particular, see the account_holder_name we'd return in the webhook from Sandbox: J \u0026\u002B SANDBRIDGE JR.

In Sandbox (for example using the mock-provider "mock-payments-gb-redirect") we return on purpose some fields with special characters. This is because is possible that in Production special characters are also returned. When a special character is returned in the body, is important that everything being received is not serialised or manipulated in any way - but passed exactly as it is as the body when verifying the signature.


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.

Handling duplicated webhooks

TrueLayer can't guarantee that you’ll only receive a single webhook notification for each payment status. As such, your integration should have logic that can handle receiving multiple webhooks for a given payment status.

For example, imagine TrueLayer sends a payment_executed webhook, but doesn't receive a 200 response due to network issues from the recipient. In this case, TrueLayer sends an extra executed webhook as it cannot confirm the previous one was received, regardless of the current status of the payment.

We ensure that the event_id is the same for duplicated webhooks, but you should check the payment_id or payout_id as well.