Callbacks

Pliant uses HTTPS callbacks (also known as webhooks) to notify partners about specific events that occur within our platform.

Partners can subscribe to these callbacks via the API. Once subscribed, your system will be notified whenever the specified event type occurs.

Callback Structure

  • Callbacks events are sent as HTTPS POST requests.
  • Each callback includes a structured body containing event-specific information.
  • Callbacks can be:
  • Callback authenticity can be verified, see below

Each callback request contains the following HTTP headers to assist with identification and processing:

HeaderDescriptionExample Value
X-Organization-IdThe organization associated with the event.54e6e7e7-0c23-4a54-bccb-2d3ff2fd02df
X-Partner-Organization-Id(Optional) The partner’s reference to the organization.abcdf1234
X-Payment-Program-IdThe payment program related to the event.54e6e7e7-0c23-4a54-bccb-2d3ff2fd02df
X-Callback-IdUnique UUIDv4 for the callback. Useful for deduplication.12a03093-eaad-4ae5-9d8d-42e7f6f8b7c6
X-Callback-Created-AtUTC timestamp of when the callback was created. May be used to re-order delayed callbacks.2024-03-15T15:55:58.623375258Z
webbhook-*Three headers for callback verificationSee below

Callback Semantics

ℹ️

We only send the callback. It's up to you to ensure successful processing on your side.

Callbacks are delivered with at least once semantics. This means:

  • A callback may be sent more than once for the same event.
  • While we attempt to deduplicate events, due to the asynchronous and distributed nature of the system, duplicates cannot be fully avoided.
  • You should implement idempotent endpoints to safely handle repeated deliveries.
📘

95% of callbacks are delivered within 10 seconds of the business event. The remaining 5% may take up to 1–2 minutes, depending on system load and event sequencing.

Retry Strategy

If your system responds with a non-2xx HTTP status code, Pliant will retry the callback automatically:

  • We do not follow redirects (e.g., 301, 302).
  • Retry pattern:
    • 4xx error codes are retried around five times
    • all other error codes are retried around twenty times, in a pattern with growing times between each retry
  • Based on the error code, the callback can be marked as failed (either after few retries or twenty).
  • You can request a manual retry by providing the callback ID(s) and the desired timeframe.

Important Notes

Callbacks reflect events in a fully asynchronous system. This means:

  • Order is not guaranteed — callbacks may arrive out of sequence.
  • Timing is not deterministic — most callbacks are fast, but some may be delayed depending on load and processing time.
  • Always use the X-Callback-Created-At timestamp if you need to reconstruct event order on your end.

Authentication

For information on securing your callback endpoints, please refer to the guide on Authenticated Callback usage.

Callback Authenticity

All callbacks sent by Pliant are cryptographically signed so that consumers can verify that they originate from our systems. The signing process follows the Standard Webhooks Signature Scheme.

Each callback attempt includes three HTTP headers:

  • webhook-id: Identical to the historical header X-Callback-Id.

  • webhook-timestamp: Unix timestamp (seconds since epoch) indicating when this callback attempt was generated.

  • webhook-signature: A versioned Ed25519 signature over the callback data.

    Format: v1a,<base64-encoded-signature>. The v1a prefix identifies the signature scheme version.

Both "callback" and "webhook" terminology appear due to compatibility with the Standard Webhooks specification.

Signature Scheme

Each payment program has its own Ed25519 key pair used to sign callbacks. You can retrieve your program’s public key from: /api/partner-management/signing/jwks.json

The endpoint requires authentication using your API credentials.

Signed content format

The signature is computed over the following three components, concatenated verbatim and separated by dots:

<webhook-id>.<webhook-timestamp>.<payload>

Rules for concatenation and serialization:

  • <payload> is exactly the raw HTTP request body, byte-for-byte, including whitespace and ordering. Do not reformat or re-serialize JSON.

  • Use UTF-8 encoding for all text components.

  • No trailing spaces or newlines are added or removed.

  • <webhook-timestamp> must be included as an integer string, exactly as received.

Verifying a Callback

To verify a callback:

  1. Validate timestamp tolerance

    Compare webhook-timestamp to your system time.

    We recommend enforcing an allowable skew to mitigate replay attacks—for example ±5 minutes. Select a window that fits your operational requirements.

  2. Reconstruct the signed content

    Concatenate the values exactly as defined.

  3. Verify the signature

    • Extract the Base64-encoded portion of webhook-signature (after the v1a, prefix).
    • Use the Ed25519 public key from the JWKS to verify the detached signature against the reconstructed content.

Example

A callback may be delivered with headers and payload like the following:

webhook-id: fcc8b37b-9f9a-4e2c-bd0d-4e0610d92ec5
webhook-timestamp: 123456789
webhook-signature: v1a,t6CRz6htNVgx9O1y4PjSeBFZRlhu4fk0fZJy8pYEkgSp4hiOaowWLLzJM737t3jTZNlcw/Tc+m/8tGxm95qsAw==

{"test": true}

Signature verification in Kotlin

val jwks = JWKSet.parse("""{"keys":[{"kty":"OKP","use":"sig","crv":"Ed25519","kid":"x4_cic8wT7GiHGhLR7OA5VAC6U3Dn5MDWNTDlCk_Qe4","key_ops":["verify"],"x":"ybZX6AKkLQ2fPIUb_RelEpB7gThMVtuPiDn5upltFxI","alg":"EdDSA"}]}""")
val spki = SubjectPublicKeyInfo(
    AlgorithmIdentifier(EdECObjectIdentifiers.id_Ed25519),
    jwks.keys.first().toOctetKeyPair().x.decode(),
)
val publicKey = KeyFactory.getInstance(Curve.Ed25519.stdName).generatePublic(X509EncodedKeySpec(spki.encoded))

val payload = """{"test": true}""" // raw HTTP body
val timestamp = 123456789L
val callbackId = "fcc8b37b-9f9a-4e2c-bd0d-4e0610d92ec5"
val signature = "t6CRz6htNVgx9O1y4PjSeBFZRlhu4fk0fZJy8pYEkgSp4hiOaowWLLzJM737t3jTZNlcw/Tc+m/8tGxm95qsAw=="

val toVerify = "$callbackId.$timestamp.$payload".toByteArray(StandardCharsets.UTF_8)

val sig = Signature.getInstance(Curve.Ed25519.stdName)
sig.initVerify(publicKey)
sig.update(toVerify)

val result = sig.verify(Base64.getDecoder().decode(signature))
assertThat(result).isTrue()