Skip to main content

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​

  1. Go to Settings > Webhooks in your dashboard.
  2. Enter your endpoint URL. It must be https and cannot resolve to a private, loopback, or link-local address.
  3. Choose which event types to receive.
  4. Optionally turn on include applicant name to have application.* events carry the applicant's first_name / last_name. Off by default.
  5. 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​

TypeSent when
application.submittedAn applicant (or staff on their behalf) submits an application.
application.updatedReserved for stage and admission changes. Not sent yet; subscribe now and it starts arriving when released.
decision.sentA decision is sent to the applicant.
decision.respondedThe applicant accepts or declines a sent decision.
payment.completedA payment completes, from either supported processor.
webhook.testSent 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:

HeaderDescription
X-UAPPLY-Event-IdSame value as event_id.
X-UAPPLY-Event-TypeSame value as type.
X-UAPPLY-Institution-IdSame value as institution_id.
X-UAPPLY-TimestampUnix seconds at signing time.
X-UAPPLY-Signaturev1=<hex>, see below.
Idempotency-KeyEqual to event_id.
User-AgentUAPPLY-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:

AttemptDelay before it
1immediately
21 minute
35 minutes
430 minutes
52 hours
612 hours
724 hours
824 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.