Webhooks

Receive events from Akahu Apply as applications, documents, and reports change

Akahu Apply can send you a webhook when something changes in your organisation, such as a document finishing processing or a report becoming available.

ℹ️

Webhooks are available to organisations that have Akahu Apply API access enabled, and can only be managed by users with the “admin” role.

Creating a webhook endpoint

Webhook endpoints are managed in the Akahu Apply web portal.

1. Open your webhook settings

Log in to Akahu Apply as a user with the “admin” role and navigate to Settings > Webhooks.

The Akahu Apply account menu with Settings highlightedThe settings page with Webhooks highlighted in the settings menu

2. Add an endpoint

Click “Create”, then in the “Add webhook endpoint” dialog enter a name and the URL that events will be delivered to.

The URL must be publicly reachable over HTTPS on the default port, and must use a hostname rather than an IP address.

The webhooks settings page with the Create button highlightedThe Add webhook endpoint dialog with a name and endpoint URL filled in

Each organisation can register up to five endpoints, and no two endpoints can share the same URL.

3. Choose the events to receive

“All events” sends every event type, including any that are added in future. “Select events” limits the endpoint to the types you tick.

Each endpoint receives events for the whole organisation, including activity from the web portal as well as the API.

The Add webhook endpoint dialog showing the event type selection

4. Choose whether to be emailed about failures

Tick “Notify on failed deliveries” and choose a member of your organisation. They will be emailed when an event is abandoned after its final retry, and again if the endpoint is disabled. These emails are limited to one per endpoint per day, and will be cancelled if the chosen member is removed from your organisation.

The Add webhook endpoint dialog with failure notifications on and a recipient chosen

5. Send a test event

Save the endpoint, then choose “Send test event” from its menu to check that your handler is reachable and verifying signatures correctly. A test event has the type test, and is only sent to the endpoint you triggered it from.

The endpoint's menu with Send test event highlighted

6. Check delivery status

“View delivery status” shows when the endpoint last succeeded and last failed. A failure includes the request ID, the response status, and the start of the response body that your endpoint returned.

The delivery status dialog for a webhook endpoint

What a webhook looks like

Each event is delivered as a POST request with a JSON body:

{
  "_id": "evt_tz4a98xxat96iws9zmbrgj3a",
  "emitted_at": "2026-08-05T02:31:10.482Z",
  "payload": {
    "type": "report:completed",
    "_org": "org_s5l7kht0o1oknb0fl15i31rq",
    "_application": "application_k1qzpjjv7wbidaqj6g4rjtpr",
    "_report": "report_hlyt2oipojdbz8gteqtwmoi7"
  }
}

payload.type identifies the event. The remaining payload fields are the IDs of the records it relates to, which you can use to fetch the current state from the API.

Two headers are sent with every request:

HeaderDescription
X-Akahu-Apply-SignatureA detached JWS over the request body, signed with ES256. See Verifying a webhook.
X-Akahu-Apply-Request-IdUnique per delivery attempt, including retries. Quote it when asking us about a delivery. It is not an idempotency key, use the event _id for that.

Your endpoint must return a 2xx status within 10 seconds. Anything else, including a redirect, counts as a failed delivery. If your handler has work to do, acknowledge the event first and do the work afterwards.

Events

Every event type, and the payload it carries, is listed under Webhooks in our API Reference.

New event types may be added over time. An endpoint set to “All events” will start receiving them, so ignore any payload.type that you don't recognise.

Verifying a webhook

The X-Akahu-Apply-Signature header is a detached JWS (RFC 7515 Appendix F) signed with ES256. The payload segment of the serialisation is empty, so verification needs the raw request body exactly as it was received, before any JSON parsing.

To verify a webhook:

  1. Read the kid from the protected header, which is the first segment of the signature, base64url encoded JSON.
  2. Fetch the key with that kid from https://api.apply.akahu.nz/.well-known/jwks.json.
  3. Verify the signature over <protected header>.<base64url of the raw body> using that key, accepting ES256 and nothing else.

The JWKS can be cached for five minutes. Fetch it again whenever you see a kid you don't hold, as signing keys are rotated from time to time. During a rotation the document lists both keys, so an in-flight retry signed with the previous key still verifies.

Most JOSE libraries can verify the signature once the body is reattached. Examples using Node.js, Python, Java, and C# are supplied below.

These examples can be run via the command line with the following arguments:

  • A path to the file containing the signature
  • A path to the file containing the raw body

This allows you to test verification of a delivery that you have captured.

// requires jose: npm install jose
import { createRemoteJWKSet, flattenedVerify } from "jose";
import { readFile } from "node:fs/promises";

const jwks = createRemoteJWKSet(
  new URL("https://api.apply.akahu.nz/.well-known/jwks.json")
);

/**
 * Verify a webhook, throwing if the signature is not valid.
 *
 * @param {string} signature - the value of the "X-Akahu-Apply-Signature" header
 * @param {Buffer} body - the raw request body, before any parsing
 * @returns {Promise<void>}
 */
async function verifyWebhook(signature, body) {
  const [protectedHeader, , sig] = signature.split(".");

  await flattenedVerify(
    {
      protected: protectedHeader,
      payload: Buffer.from(body).toString("base64url"),
      signature: sig,
    },
    jwks
  );
}

const [signaturePath, bodyPath] = process.argv.slice(2);

// the "X-Akahu-Apply-Signature" header: "eyJhbGciOiJFUzI1NiIsImtpZCI6..."
const signature = (await readFile(signaturePath, "utf8")).trim();
// the raw request body, before any parsing: {"_id":"evt_tz4a98xxat96iws9zmbrgj3a",...}
const body = await readFile(bodyPath);

try {
  await verifyWebhook(signature, body);
  console.log("This webhook is from Akahu Apply!");
} catch (error) {
  console.log("Invalid webhook caller!");
  process.exit(1);
}
# requires PyJWT: pip install "pyjwt[crypto]"
import base64
import sys

from jwt import PyJWKClient, api_jws

jwks = PyJWKClient("https://api.apply.akahu.nz/.well-known/jwks.json")


def verify_webhook(signature: str, body: bytes) -> None:
    """Verify a webhook, raising if the signature is not valid.

    Arguments:
    signature -- the value of the "X-Akahu-Apply-Signature" header
    body -- the raw request body, before any parsing
    """
    protected_header, _, sig = signature.split(".")
    payload = base64.urlsafe_b64encode(body).rstrip(b"=").decode()
    token = f"{protected_header}.{payload}.{sig}"

    kid = api_jws.get_unverified_header(token)["kid"]
    key = jwks.get_signing_key(kid).key

    api_jws.decode_complete(token, key, algorithms=["ES256"])


def main() -> None:
    signature_path, body_path = sys.argv[1:3]

    # the "X-Akahu-Apply-Signature" header: "eyJhbGciOiJFUzI1NiIsImtpZCI6..."
    with open(signature_path) as f:
        signature = f.read().strip()
    # the raw request body, before any parsing: {"_id":"evt_tz4a98xxat96iws9zmbrgj3a",...}
    with open(body_path, "rb") as f:
        body = f.read()

    try:
        verify_webhook(signature, body)
    except Exception:
        print("Invalid webhook caller!")
        sys.exit(1)

    print("This webhook is from Akahu Apply!")


if __name__ == "__main__":
    main()
// requires com.nimbusds:nimbus-jose-jwt
package nz.akahu.apply;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSObject;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.JWSVerifierFactory;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.Base64URL;

import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Key;
import java.util.List;
import java.util.Set;

public class AkahuApplyWebhook {
    private static final Set<JWSAlgorithm> ACCEPTED_ALGORITHMS = Set.of(JWSAlgorithm.ES256);
    private static final JWSVerifierFactory VERIFIERS = new DefaultJWSVerifierFactory();
    private static final JWKSource<SecurityContext> JWKS = jwks();

    private static JWKSource<SecurityContext> jwks() {
        try {
            return JWKSourceBuilder
                    .create(URI.create("https://api.apply.akahu.nz/.well-known/jwks.json").toURL())
                    .build();
        } catch (Exception error) {
            throw new IllegalStateException(error);
        }
    }

    /**
     * Verify a webhook, throwing if the signature is not valid.
     *
     * @param signature the value of the "X-Akahu-Apply-Signature" header
     * @param body      the raw request body, before any parsing
     */
    public static void verifyWebhook(String signature, byte[] body) throws Exception {
        String[] parts = signature.split("\\.", -1);
        JWSObject jws = new JWSObject(
                new Base64URL(parts[0]), new Payload(body), new Base64URL(parts[2]));

        // No keys are returned if the header declares an algorithm we don't accept.
        List<Key> keys = new JWSVerificationKeySelector<>(ACCEPTED_ALGORITHMS, JWKS)
                .selectJWSKeys(jws.getHeader(), null);

        for (Key key : keys) {
            // The verifier is chosen from the header, so a new algorithm only needs
            // adding to ACCEPTED_ALGORITHMS.
            if (jws.verify(VERIFIERS.createJWSVerifier(jws.getHeader(), key))) {
                return;
            }
        }

        throw new SecurityException("Invalid webhook signature");
    }

    public static void main(String[] args) throws Exception {
        // the "X-Akahu-Apply-Signature" header: "eyJhbGciOiJFUzI1NiIsImtpZCI6..."
        String signature = Files.readString(Path.of(args[0])).trim();
        // the raw request body, before any parsing: {"_id":"evt_tz4a98xxat96iws9zmbrgj3a",...}
        byte[] body = Files.readAllBytes(Path.of(args[1]));

        try {
            verifyWebhook(signature, body);
        } catch (Exception error) {
            System.out.println("Invalid webhook caller!");
            System.exit(1);
        }

        System.out.println("This webhook is from Akahu Apply!");
    }
}
// requires Microsoft.IdentityModel.JsonWebTokens
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

public static class AkahuApplyWebhook
{
    private static readonly HttpClient Http = new();

    /// <summary>Verify a webhook, throwing if the signature is not valid.</summary>
    /// <param name="signature">The value of the "X-Akahu-Apply-Signature" header.</param>
    /// <param name="body">The raw request body, before any parsing.</param>
    public static async Task VerifyWebhookAsync(string signature, byte[] body)
    {
        var parts = signature.Split('.');
        var token = $"{parts[0]}.{Base64UrlEncoder.Encode(body)}.{parts[2]}";

        // Fetched on every verification. Hold it for five minutes in your own cache,
        // and fetch again when you see a kid that cache doesn't hold.
        var jwks = await Http.GetStringAsync("https://api.apply.akahu.nz/.well-known/jwks.json");

        var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, new TokenValidationParameters
        {
            IssuerSigningKeys = JsonWebKeySet.Create(jwks).GetSigningKeys(),
            ValidAlgorithms = ["ES256"],
            // The body is not a claims set, so there is nothing but the signature to check.
            ValidateIssuer = false,
            ValidateAudience = false,
            ValidateLifetime = false,
        });

        if (!result.IsValid)
        {
            throw result.Exception;
        }
    }

    public static async Task<int> Main(string[] args)
    {
        // the "X-Akahu-Apply-Signature" header: "eyJhbGciOiJFUzI1NiIsImtpZCI6..."
        var signature = File.ReadAllText(args[0]).Trim();
        // the raw request body, before any parsing: {"_id":"evt_tz4a98xxat96iws9zmbrgj3a",...}
        var body = File.ReadAllBytes(args[1]);

        try
        {
            await VerifyWebhookAsync(signature, body);
        }
        catch (Exception)
        {
            Console.WriteLine("Invalid webhook caller!");
            return 1;
        }

        Console.WriteLine("This webhook is from Akahu Apply!");
        return 0;
    }
}

Reject the request if verification fails. Anyone can send a request to your endpoint, and the signature is what tells you the event came from Akahu Apply.

Example data

Below is a real delivery, signed with a key that the JWKS still publishes. Use it to check that your endpoint accepts the valid signature and rejects the invalid one. Save each block to its own file, taking care not to add a trailing newline to the request body, then pass the signature and the body to the example.

{"_id":"evt_tz4a98xxat96iws9zmbrgj3a","emitted_at":"2026-08-05T02:31:10.482Z","payload":{"type":"report:completed","_org":"org_s5l7kht0o1oknb0fl15i31rq","_application":"application_k1qzpjjv7wbidaqj6g4rjtpr","_report":"report_hlyt2oipojdbz8gteqtwmoi7"}}
eyJhbGciOiJFUzI1NiIsImtpZCI6IjFiN2RjMTFkLTM3ODEtNDM5Mi05ZjY0LTUwZGI2NWYzODk4NyJ9..Z1IcrJaqMr2FSUIj7eaxFy4lFfSeDLX3slxhSzpzjcJ_0mkJb8qJL_6u8MrjRVhbcFVXT-5z_XNXObit13gksg
eyJhbGciOiJFUzI1NiIsImtpZCI6IjFiN2RjMTFkLTM3ODEtNDM5Mi05ZjY0LTUwZGI2NWYzODk4NyJ9..h21EHzMckSg9zE_mn10z3KpVGrVaZmp7i28AddqLEp0na9WkNizHdyFiMexvfghvf8RoHAtMgSv809pKzzmkSQ

The invalid signature carries the same kid as the valid one, but was signed with a different key. Anything that reads the header without verifying the signature over the body will wrongly accept it.

Retries

A delivery attempt fails if the request times out after 10 seconds, the connection fails, or the response status is anything other than 2xx.

A failed event is retried up to 20 times, backing off exponentially with jitter to a maximum of 15 minutes between attempts. After the final attempt the event is abandoned, and the failure is recorded against the endpoint.

Events that are queued for an endpoint which is disabled or deleted before delivery are dropped rather than held.

If an endpoint goes a month without a successful delivery, the next abandoned event disables it. No further events are sent until you enable it again from the portal.

Ordering and idempotency

The event _id is stable across delivery attempts, so use it as an idempotency key and ignore an event that you have already handled.

Events for an endpoint are queued in the order they occur, but a retried event can arrive after a later one. Treat an event as a signal to fetch the current state from the API rather than as the state itself.


Did this page help you?