Webhooks
Get Ardent events pushed to your server, signed and verifiable.
Instead of polling, subscribe to events and Ardent will POST them to your URL as they happen. Create and manage webhooks in your developer dashboard.
Events
| Event | When | Payload |
|---|---|---|
petition.signed | a petition gets a new signature | petition_slug, signatures_count |
campaign.published | a campaign goes live | slug, title, category, goal_amount, target_country |
campaign.updated | a live campaign's details change | slug, title |
campaign.goal_reached | a campaign hits its goal | slug, title, goal_amount, raised_amount |
event.published | an event is published | slug, title, start_date, is_online, is_paid |
event.ticket_sold | a paid ticket is confirmed | event_slug, quantity |
event.cancelled | an event is cancelled or moved online | slug, title, status |
event.updated | a published event is rescheduled | slug, title, start_date, previous_start_date |
event.refunded | a ticket refund completes | event_slug, amount_pesewas, amount_minor, currency |
job.posted | a job posting goes live | slug, title, employment_type, workplace_type, location, is_featured |
job.closed | a posting stops accepting applications | slug, title, status |
application.received | someone applies to a posting | posting_slug, posting_title, application_count |
application.status_changed | an application moves in the pipeline | posting_slug, from_status, to_status |
review.received | a new review is published | entity_slug, entity_type, overall_rating, verification_status |
review.flagged | a review enters moderation | entity_slug, entity_type |
review.responded | an entity replies to a review | entity_slug, entity_type |
entity.claim.submitted | a listing claim is filed | entity_slug, entity_type |
entity.claim.approved | a listing claim is approved | entity_slug, entity_type |
entity.question.asked | a public question is posted on a listing | entity_slug, entity_type |
entity.suggestion.submitted | an edit to a listing is suggested | entity_slug, entity_type |
entity.update.published | an entity posts an update | entity_slug, entity_type, kind |
entity.verification.approved | an entity verification is approved | entity_slug, entity_type, verification_type |
social.post.published | a PUBLIC post is published | id, kind, activity_type, author_handle, author_display_name, author_kind, published_at |
social.follower.gained | somebody follows you | followee_handle, follower_handle, follower_display_name, followed_at |
social.mention.created | you are mentioned in a public post | post_id, mentioned_handle, author_handle, author_display_name |
amount_pesewas and amount_minor on event.refunded
Both fields carry the same number, the refunded amount in the minor unit of currency.
amount_pesewas is the original field name and is kept for compatibility: your endpoint is
already reading it and it is not going away. It is a misnomer once an event is priced in
something other than cedis, which is why amount_minor was added beside it.
Read currency alongside either one. An amount of 20000 is GHS 200.00 or NGN 200.00
depending on it, and neither field tells you which on its own. New integrations should use
amount_minor. The same pair appears as price_pesewas and price_minor on the public events
endpoint, for the same reason.
Some events are yours alone
Most topics above are broadcast: the content is already public, so every subscription listening for the topic receives every event.
social.follower.gained and social.mention.created are scoped. They are delivered only to
subscriptions owned by the account the event is about. You will never receive a follower event
for somebody else's profile.
That distinction is not cosmetic. Both handles in a follower event belong to public profiles, but the edge between them is not public, and an endpoint collecting broadcast edges would reconstruct the whole social graph. The same reasoning is why the read API has no endpoint for follows, connections or group members at all.
social.post.published is broadcast, because it fires only for a post whose author chose a
public audience, which anybody can already read at its permalink. A post limited to followers, to
connections, or to a group never produces a webhook, and a mention inside such a post does not
either.
Payloads contain public fields only: never donor, signer, organizer, attendee, candidate, reviewer, or claimant personal data.
This is deliberate for the job events. application.received tells you a posting got an
application and how many it now has; it does not tell you who applied. application.status_changed
gives you the coarse pipeline status only. No name, email, phone, CV, cover letter, screening
answer, or candidate id ever crosses a webhook boundary, because a job seeker's identity leaking
to a third-party endpoint could cost them their current job. Use the employer dashboard or the
authenticated API for applicant detail.
Delivery
Each delivery is a POST with a JSON body and these headers:
Content-Type: application/json
X-Ardent-Event: petition.signed
X-Ardent-Signature: t=1718900000,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08Respond with any 2xx to acknowledge. Non-2xx (or a timeout) is retried with exponential
backoff for up to 8 attempts, after which the delivery is dead-lettered. You can replay
it from the dashboard. You can also send a test event to any endpoint.
Verifying the signature
v1 is the hex HMAC-SHA256 of <t>.<raw-request-body>, keyed by your subscription's signing
secret (shown once when you create or rotate the webhook). Always verify before trusting a
payload, and reject timestamps outside a few minutes to prevent replays.
// Node - use the RAW request body, not a re-serialized object.
import crypto from 'node:crypto'
function verify(secret, signatureHeader, rawBody) {
const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')))
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
return ok && fresh
}<?php
function ardent_verify(string $secret, string $header, string $rawBody): bool {
parse_str(str_replace(',', '&', $header), $p);
$expected = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $p['v1'] ?? '') && abs(time() - (int) $p['t']) < 300;
}Security notes
- Only HTTPS endpoints are accepted, and Ardent will not deliver to private, loopback, or link-local addresses.
- Treat the signing secret like a password. If it leaks, rotate it from the dashboard.
- Delivery is at-least-once. Handle the occasional duplicate idempotently.
Last updated: 24 August 2026
