Security · FAPI · Webhooks

Receiving Event Notifications 2 min read

When the API Hub delivers a webhook event (such as a Payment Status or Consent Status change), it POSTs a JWE compact serialisation to your registered webhook URL. The JWE is encrypted with your public Encryption Certificate, and the decrypted payload is a signed JWT (JWS) containing the event.

This page covers how to correctly decrypt, verify, and validate the event in line with the FAPI 2.0 Security Profile.

01 Step 1

Read the kid and select the right key

The JWE protected header identifies which of your registered encryption keys was used via the kid claim. Decode the first segment to read it before attempting decryption:

typescript
function getJweKid(jweString: string): string {
  const [headerB64] = jweString.split('.')
  const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString())
  return header.kid
}

const kid = getJweKid(jweString)
const privateKey = myKeyStore.getPrivateKey(kid)
Multiple encryption keys

Keep retired private keys available until you are confident no in-flight events were encrypted with them — the kid tells you exactly which key to use.

02 Step 2

Decrypt the JWE

Decrypt the JWE using the private key selected above. The result is the inner JWS:

typescript
import { compactDecrypt, importPKCS8 } from 'jose'

const privateKeyPem = myKeyStore.getPrivateKeyPem(kid)
const privateKey = await importPKCS8(privateKeyPem, 'RSA-OAEP-256')

const { plaintext } = await compactDecrypt(jweString, privateKey)
const jwsString = new TextDecoder().decode(plaintext)
03 Step 3

Verify the JWS signature and validate claims

The inner JWS is signed by the API Hub. Verify the signature using the Hub's public JWKS, then validate the JWT claims.

typescript
import { createLocalJWKSet, jwtVerify } from 'jose'

// Fetch Hub JWKS from the Hub's .well-known/openid-configuration
const hubJwks = createLocalJWKSet(await fetchHubJwks())

const { payload } = await jwtVerify(jwsString, hubJwks, {
  issuer:   expectedLfiIssuer,   // see security checks below
  audience: process.env.CLIENT_ID,
})

return payload.message
04 Security Checks

FAPI-required claim validation before processing

After decrypting and verifying the signature, validate the following claims before processing the event. These checks are required by the FAPI 2.0 Security Profile.

CheckClaimWhat to verify
IssuerissMust match the issuer of the LFI that owns the consent — cross-reference with the ConsentId in Meta. Reject events where iss does not match the expected LFI to prevent an event from one LFI being replayed against a consent held at another.
AudienceaudMust contain your application's client_id. Reject events addressed to a different client.
ExpiryexpMust be in the future. Reject expired tokens.
Not BeforenbfIf present, must not be in the future.
ReplayjtiIf present, record the value and reject any future event with the same jti. This prevents a delivered event from being replayed.
Consent matchMeta.ConsentIdMust correspond to a consent your application created. Discard events for unknown consent IDs.
Issuer validation is critical

Always verify that iss corresponds to the LFI tied to the consent in Meta.ConsentId. Without this check, a malicious actor could craft or replay an event from a different LFI to influence your application's view of a consent it holds elsewhere.

05 Full Example

End-to-end webhook handler with FAPI checks

typescript
import { compactDecrypt, createLocalJWKSet, importPKCS8, jwtVerify } from 'jose'

async function processWebhookEvent(jweString: string) {
  // 1. Read kid from the JWE protected header and select the right private key
  const kid = getJweKid(jweString)
  const privateKey = await importPKCS8(
    myKeyStore.getPrivateKeyPem(kid),
    'RSA-OAEP-256',
  )

  // 2. Decrypt the JWE → inner JWS string
  const { plaintext } = await compactDecrypt(jweString, privateKey)
  const jwsString = new TextDecoder().decode(plaintext)

  // 3. Peek at the unverified payload to find which consent this event is about
  const [, payloadB64] = jwsString.split('.')
  const unverified = JSON.parse(Buffer.from(payloadB64, 'base64url').toString())
  const consentId = unverified?.message?.Meta?.ConsentId

  // 4. Look up the expected LFI issuer from your consent store
  const expectedIssuer = myConsentStore.getIssuer(consentId)
  if (!expectedIssuer) throw new Error(`Unknown consentId: ${consentId}`)

  // 5. Verify signature and standard claims (iss, aud, exp, nbf)
  const hubJwks = createLocalJWKSet(await fetchHubJwks())
  const { payload } = await jwtVerify(jwsString, hubJwks, {
    issuer:   expectedIssuer,
    audience: process.env.CLIENT_ID,
  })

  // 6. Replay check
  if (payload.jti && seenJtis.has(payload.jti)) {
    throw new Error(`Replayed event jti: ${payload.jti}`)
  }
  if (payload.jti) seenJtis.add(payload.jti)

  return payload.message
}