Webhooks
Webhooks push events to your own endpoint as they happen, so you don't have to
poll GET /applications for changes. Configure one URL per institution from
your dashboard's Settings > Webhooks page.
Enabling webhooks
- Go to Settings > Webhooks in your dashboard.
- Enter your endpoint URL. It must be
httpsand cannot resolve to a private, loopback, or link-local address. - Choose which event types to receive.
- Optionally turn on include applicant name to have
application.*events carry the applicant'sfirst_name/last_name. Off by default. - Save. Your signing secret is shown once, right after saving, exactly like an API key: copy it now, because it can't be shown again. Use rotate to replace it if it leaks, which immediately invalidates the old one.
The same page shows a delivery log (status, attempt count, last response
code) that you can filter by status, and a send test ping button that
enqueues a webhook.test event to your configured URL so you can verify
signature checking before relying on real traffic.
Event types
| Type | Sent when |
|---|---|
application.submitted | An applicant (or staff on their behalf) submits an application. |
application.updated | Reserved for stage and admission changes. Not sent yet; subscribe now and it starts arriving when released. |
decision.sent | A decision is sent to the applicant. |
decision.responded | The applicant accepts or declines a sent decision. |
payment.completed | A payment completes, from either supported processor. |
webhook.test | Sent on demand by "send test ping". |
The request
Every delivery is a POST to your configured URL with this JSON body:
{
"event_id": "evt_7f3d2c1a9b8e4f6a5d0c1b2e3f4a5b6c",
"type": "application.submitted",
"occurred_at": "2026-09-24T09:12:00.000Z",
"institution_id": "inst_9f2a...",
"data": { /* payload for this event type, see below */ }
}
and these headers:
| Header | Description |
|---|---|
X-UAPPLY-Event-Id | Same value as event_id. |
X-UAPPLY-Event-Type | Same value as type. |
X-UAPPLY-Institution-Id | Same value as institution_id. |
X-UAPPLY-Timestamp | Unix seconds at signing time. |
X-UAPPLY-Signature | v1=<hex>, see below. |
Idempotency-Key | Equal to event_id. |
User-Agent | UAPPLY-Webhooks/1. |
Respond with any 2xx status within 10 seconds to acknowledge. Anything else,
including a redirect or a timeout, is treated as a failed delivery and
retried.
Payloads
// application.submitted / application.updated
{
"submission_id": "sub_...",
"admission_id": "adm_...",
"institution_id": "inst_...",
"program_id": "prog_...",
"cycle_id": "cyc_...",
"campus_id": "camp_..." /* or null */,
"stage_id": "submitted",
"submitted_at": "2026-09-24T09:12:00.000Z",
"submitted_by_staff": false,
"applicant": { "first_name": "Jane", "last_name": "Doe" } /* or null */
}
// decision.sent
{
"decision_id": "dec_...",
"submission_id": "sub_...",
"admission_id": "adm_...",
"institution_id": "inst_...",
"decision_status": "accepted",
"sent_at": "2026-09-24T09:12:00.000Z"
}
// decision.responded
{
"decision_id": "dec_...",
"submission_id": "sub_..." /* or null */,
"institution_id": "inst_...",
"response": "accepted",
"responded_at": "2026-09-24T09:12:00.000Z"
}
// payment.completed
{
"payment_id": "pay_...",
"submission_id": "sub_..." /* or null */,
"institution_id": "inst_...",
"amount": 50000 /* or null */,
"currency": "RWF" /* or null */,
"processor": "paypack"
}
// webhook.test
{
"message": "UAPPLY webhook test",
"institution_id": "inst_..."
}
stage_id is an institution scoped, open value, not a fixed enum, so treat it
as opaque text you compare against the stage ids in your own pipeline.
applicant on application.* events is present only when you turned on
include applicant name; otherwise it is null.
Verifying the signature
The signature proves the body came from UAPPLY and was not modified in
transit. It is an HMAC-SHA256, hex encoded, of the string
{timestamp}.{raw body}, keyed with your signing secret, where
{raw body} is the exact bytes you received (verify before any JSON parsing
or re-serialization).
Also reject any request whose X-UAPPLY-Timestamp is more than five minutes
from the current time, to defend against a captured request being replayed
later. Compare the computed signature to the header in constant time, not
with === or ==, so timing does not leak information about the secret.
Node.js
const crypto = require('crypto');
function verifyWebhook(rawBody, headers, secret) {
const timestamp = headers['x-uapply-timestamp'];
const signatureHeader = headers['x-uapply-signature'] || '';
const [, signature] = signatureHeader.split('v1=');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 5 * 60) {
throw new Error('Timestamp too old');
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature || '', 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('Invalid signature');
}
}
PHP
<?php
function verify_webhook(string $rawBody, string $timestamp, string $signatureHeader, string $secret): bool {
if (abs(time() - (int) $timestamp) > 5 * 60) {
return false;
}
$signature = str_replace('v1=', '', $signatureHeader);
$expected = hash_hmac('sha256', "$timestamp.$rawBody", $secret);
return hash_equals($expected, $signature);
}
Python
import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, timestamp: str, signature_header: str, secret: str) -> bool:
if abs(time.time() - float(timestamp)) > 5 * 60:
return False
signature = signature_header.replace("v1=", "", 1)
payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Retries and delivery guarantees
Delivery is at least once: a slow or ambiguous response can result in a
second delivery of an event you already processed, so dedupe on event_id
(or the equivalent Idempotency-Key header) before applying it.
A failed attempt is retried on this schedule, counted from the first attempt:
| Attempt | Delay before it |
|---|---|
| 1 | immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
| 7 | 24 hours |
| 8 | 24 hours |
After attempt 8 fails, the event is marked dead and not retried again.
If your endpoint fails 20 deliveries in a row, it is automatically disabled (no further events are sent) so a broken integration cannot pile up indefinitely. Fix your endpoint, then re-enable it from Settings > Webhooks, which also resets the failure count.