Webhooks

Best practices for setting up your app to receive webhooks

ℹ️

This reference is intended for developers using Akahu's enduring account connectivity. Information provided may not be relevant for those using one-off account connectivity.

Webhooks are the best way to make your application responsive to new or changing data.

Webhooks are not available for Personal Apps.

Configuring webhooks

Before your application can receive webhooks, you must register a URL that Akahu will send webhook requests to. If your Akahu application has more than one environment, you may register a different webhook URL for each environment. Reach out to our team on Slack or via email if you'd like to configure or update the webhook URL for your application.

Your webhook URL must be a valid https uri that can accept POST requests. An HTTP 200 status code must be returned by your endpoint within 5 seconds for Akahu to recognise the webhook as delivered.

Subscribing to webhooks

Your application must subscribe to each webhook that you'd like to receive on a per user basis. Generally the best time to initialise your webhook subscriptions is directly after completing the OAuth token exchange on the user's successful completion of the OAuth connection flow.

Verifying a webhook

Webhooks are signed by a private RSA key held by Akahu.

The signature can be found in the X-Akahu-Signature header (base 64 encoded).

The key ID of the signing key is sent in the X-Akahu-Signing-Key header.

Using the SDK

If you use a JavaScript backend, the easiest way to verify a webhook is using the Akahu SDK's AkahuClient.webhooks.validateWebhook method. An example of this can be found in the project readme.

Getting the signing key

In order to verify the signature, you must first retrieve the public key of the RSA keypair used to sign the webhook payload. This can be done by making a GET request to https://api.akahu.io/v1/keys/{keyID}. The response payload will contain the public signing key in PEM format.

{
  "success": true,
  "item": "-----BEGIN RSA PUBLIC KEY-----\n..."
}

Caching

You may cache the signing key (preferably for 24 hours), however whenever the key ID changes, your application must fetch the new key from Akahu, and wipe previously cached keys. This is to ensure that if key rotation happens, your application will not continue to allow webhooks signed with an expired key.

Verification

Once you have the public signing key, compute the RSA-SHA256 signature of the webhook body with PKCS1 padding and ensure it matches the base-64 encoded X-Akahu-Signature header. Examples using Node.js, Java, C#, and Python are supplied below.

const crypto = require("crypto");

// GET /keys/{keyId}
const publicKey = "-----BEGIN RSA PUBLIC KEY-----\n ...";
// request body as a string (before any parsing)
const body = "{\"webhook_type\":\"PAYMENT\", ...";
// "x-akahu-signature" header
const signature = "KdcB0al ...";

const verify = crypto.createVerify("sha256");
verify.update(body);
verify.end();
const isValid = verify.verify(
  publicKey,
  signature,
  "base64"
);

if (isValid) {
  console.log("This webhook is from Akahu!");
} else {
  console.log("Invalid webhook caller!");
}
// Code snippet contributed by Christian Mitchell (https://bankroll.co.nz)
// requires BounceCastle https://www.bouncycastle.org/java.html
package com.akahu;

import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.RSAPublicKeySpec;
import java.security.Signature;
import java.util.Base64;

public class Main {
    private static RSAPublicKey getRSAPublicKeyFromString(String publicKey) throws NoSuchAlgorithmException, InvalidKeySpecException {
        String formattedPublicKey = publicKey
                .replaceAll("\\n", "")
                .replace("-----BEGIN RSA PUBLIC KEY-----", "")
                .replace("-----END RSA PUBLIC KEY-----", "");
        org.bouncycastle.asn1.pkcs.RSAPublicKey rsaPublicKey = org.bouncycastle.asn1.pkcs.RSAPublicKey.getInstance(Base64.getDecoder().decode(formattedPublicKey));
        BigInteger modulus = rsaPublicKey.getModulus();
        BigInteger publicExponent = rsaPublicKey.getPublicExponent();
        RSAPublicKeySpec keySpec = new RSAPublicKeySpec(modulus, publicExponent);
        return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(keySpec);
    }

    private static boolean validateSignature(String requestSignature, String requestBody, String publicKey) throws Exception {
        RSAPublicKey masterPublicKey = getRSAPublicKeyFromString(publicKey);
        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initVerify(masterPublicKey);
        signature.update(requestBody.getBytes());
        return signature.verify(Base64.getDecoder().decode(requestSignature.getBytes()));
    }

    public static void main(String[] args) throws Exception {
        // GET /keys/{keyId}
        String publicKey = "-----BEGIN RSA PUBLIC KEY-----\n ...";
        // request body as a string (before any parsing)
        String body = "{\"webhook_type\":\"PAYMENT\", ...";
        // "x-akahu-signature" header
        String signature = "KdcB0al...";
        boolean result = validateSignature(signature, body, publicKey);
        if (result) {
            System.out.println("This webhook is from Akahu!");
        } else {
            System.out.println("Invalid webhook caller!");
        }
    }
}
using System;
using System.Security.Cryptography;
using System.Text;

namespace akahu
{
  class Program
  {
    public static void Main(string[] args)
    {
      // GET /keys/{keyId}
      var publicKey = "-----BEGIN RSA PUBLIC KEY-----\n ...";
      // request body as a string (before any parsing)
      var body = "{\"webhook_type\":\"PAYMENT\", ...";
      // "x-akahu-signature" header
      var signature = "KdcB0al...";

      var formattedPublicKey = publicKey
        .Replace("\\n", "")
        .Replace("-----BEGIN RSA PUBLIC KEY-----", "")
        .Replace("-----END RSA PUBLIC KEY-----", "");
      int bytesRead = 0;
      var rsa = RSA.Create();
      var pubKeyBytes = Convert.FromBase64String(formattedPublicKey);
      rsa.ImportRSAPublicKey(pubKeyBytes, out bytesRead);

      var result = rsa.VerifyData(Encoding.UTF8.GetBytes(body), Convert.FromBase64String(signature),
      HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);

      if (result) {
        Console.WriteLine("This webhook is from Akahu!");
      } else {
        Console.WriteLine("Invalid webhook caller!");
      }
    }
  }
}
import json
import base64
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature

def verify_signature(public_key: str, signature: str, request_body: bytes) -> None:
    """Verify that the request body has been signed by Akahu.

    Arguments:
    public_key -- The PEM formatted public key retrieved from the Akahu API
    signature -- The base64 encoded value from the "X-Akahu-Signature" header
    request_body -- The raw bytes of the body sent by Akahu
    """
    try:
        public_key = serialization.load_pem_public_key(public_key.encode('utf-8'))
        res = public_key.verify(
            base64.b64decode(signature),
            request_body,
            padding.PKCS1v15(),
            hashes.SHA256()
        )
        print("This webhook is from Akahu!")
    except InvalidSignature:
        print("Invalid webhook caller!")

To aid in testing this logic, use the below example data to ensure that you accept the valid signature, but reject the invalid one.

{"webhook_type":"ACCOUNT","webhook_code":"UPDATE","state":"example state","item_id":"acc_1111111111111111111111111","updated_fields":["balance"]}
-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEA1YWQaS5H27EvO3JNOH9nrl9SSSQspFWvoYy/jk9Z/4UhsXPg9S8s
cXKPSVsZb78DXQs8EZDQBHWlVU1VKxtP7fL8EW0bcer0HIuwxKIYMP9IHdmbzOOg
LJC8l2YNn7FqUKE1ltJgLct4UqyTF11jQdKHfhBV9DXtUP9vaFNfFzK1zEwKGggD
sVkwFyna7UoW37l5ynV0BPTaVXZ6sVWoyvxjorcLqjUBCgIcGyHXkAxElsPSBRbE
kydSvePKhe06tn6Ng+PPPJUIKzKMdB3cjKmi5Gsf7JIKRFDoY35oZsoYIRwsgujS
9uFIlDoe0N44XuyXBtLnO2DrJ2yVKkUl/QIDAQAB
-----END RSA PUBLIC KEY-----
FFcDepzALfLqD2Ljua+A1l3eZXHgUpTLWhGQC9OfYeWZX09JwF41F+T/lnKS/P8wP9Ox5eKFU8zhcnjLZ6qJUHgKtUbWUnepynM9bWi6WrkG36sbgsKeg0F0VTkM7SDFy93Vx0rNoJSCt/u87fNpOvEwIn7S7zoVlp5LfwXyispBVM3WpfMs/SDebj2CY3Ir/jqAUmNSTON0rn8+m4My6UKPBAwQCmlHzN4+1zjIJjvWc5Ez78mJUyEfx1qmM1VW2gbWYT3HuVjmGuNrPYQxuIHW6n7q31cKsa/OEVWixxzcUH3MtZvn/LeTMpKg2FmNNfVYUTkd67VxWDj179gm2A==
g+A/e8ud9eDpQNva8RxE7h0Y+8HWIeR+Q6Lefv5R4D8HuPdpBtLgPzgkWPxmQo9mKHYm5iq3apGB95Gu/gFuO8XkVYYx80b0jR8rX6QWWfhBR7MkWIFD1paaKMwXJLfWiqP/3FbMSC7rrE/iOipuZaXRYWW6393jAgtinwzv4OsNYGWNFSeXiTkgcsMDH842t7YvX5GeeT5iT/iQxlflBpkXjmcmAaG2ba2YM/5iU7JjIrvwtZis2Vr196sA+lZKmsp8YnlZ9r++cfaPdAl48GUyHdBxDWt8SM09X7pfdbnMJccExszdR1Mx+aBXRVJs/+3fd2tC5ostDwplrH1CmQ==

Retry logic

Akahu will attempt to deliver your webhook event for up to three days. An attempt will be sent approximately every hour.

If initial delivery fails and you delete your webhook before a retry attempt is made, no retry attempts will be made.

Disable logic

Akahu may disable webhooks if your endpoint fails to return a 200 HTTP status code for multiple events over multiple days.

Event handling

Handling webhook events correctly is critical to ensuring that your apps business logic works correctly.

Akahu can't guarantee delivery of events in the order in which they are generated. For example, making a payment might generate these 2 webhooks.

  • payment: status = 'READY'
  • payment: status = 'SENT'

If the 'READY' webhook fails due to network connectivity or other reasons outside of our control, it may be retried and delivered after the 'SENT' webhook. Therefore you may want to fetch the whole object from the Akahu API using the item_id to get up to date data.

What a webhook looks like

Akahu has a hierarchical webhook type structure.

At a high level there is webhook_type which is the type of object that triggers the webhook (for example a USER, ACCOUNT orTRANSACTION). Within each webhook_type there are defined webhook_codes which are the type of event that triggered the webhook (eg. UPDATE, DELETE).

All webhooks have the following basic structure:

{
  "webhook_type": "<string>",
  "webhook_code": "<string>",
  "state": "<string>", // If supplied when creating the webhook
}

The types of webhook supported by Akahu are defined below:

TOKEN

CodeDescriptionAdditional Fields
DELETEThis User Access Token has been revoked.item_id The User Access Token.

ACCOUNT

CodeDescriptionAdditional Fields
CREATEA new account has been connected.item_id The account ID.
UPDATEAn account has been updated. The fields that have changed are included in the payload so that recipients can determine if the update is relevant (e.g. an app to help with tax might not care about auto_payments).item_id The account ID. updated_fields An array of strings.
DELETEAn account has been deleted.item_id The account ID.
WEBHOOK_CANCELLEDThis webhook has been cancelled, for example if the user revokes your app.-

TRANSACTION

CodeDescriptionAdditional Fields
INITIAL_UPDATEAkahu has retrieved historic transactions for an account after initial connection. NOTE: This webhook will only be sent for accounts newly connected to Akahu. If the user has previously connected accounts to Akahu for use by another app, your app will only ever receive DEFAULT_UPDATE webhooks for those accounts.item_id The account ID. new_transactions The number of new transactions. new_transaction_ids An array of transaction IDs for the new transactions.
DEFAULT_UPDATEAkahu has retrieved new transactions or updated existing transactions.item_id The account ID. new_transactions The number of new or updated transactions. new_transaction_ids An array of transaction IDs for the new or updated transactions.
DELETEOne or more transactions have been deleted.item_id The account ID. removed_transactions An array of transaction IDs.
WEBHOOK_CANCELLEDThis webhook has been cancelled, for example if the user revokes your app.-

TRANSFER

CodeDescriptionAdditional Fields
UPDATEA transfer has changed its status.item_id The transfer ID. status The new status. status_text A description of the status, if present.
RECEIVEDThe funds have arrived at the destination account.item_id The transfer ID. received_at The time that Akahu identified the funds as received.
WEBHOOK_CANCELLEDThis webhook has been cancelled, for example if the user revokes your app.-

PAYMENT

CodeDescriptionAdditional Fields
UPDATEA payment has changed its status.item_id The payment ID. status The new status. status_code If applicable, the payment Status Code as described here. status_text If applicable, the payment Status Text as described here.
RECEIVEDThe funds have arrived at the destination account. This webhook will only be sent under certain conditions, so we do not recommended implementing core functionality that relies on it. See our Making a Payment guide for more information.item_id The payment ID. received_at The time that Akahu identified the funds as received.
WEBHOOK_CANCELLEDThis webhook has been cancelled, for example if the user revokes your app.-