JSON API
For sites that want their own donation form, or a campaign progress bar in their own design. If you only need a working donation form, the embed is two lines and needs none of this.
Read this before building a form
Submitting donations and registrations requires a one-off setup step from Hopely: your website’s hostnames must be registered with our bot-protection provider. Until that is done the challenge widget renders normally and every submission comes back bot_check_failed, which looks like a bug in your code and is not. Email us the hostnames you will use before you start. Read-only endpoints need no such step.
Your key
Copy it from Settings → Website integration. It looks like pk_live_… and identifies one organisation.
It is not a secret. It is designed to sit in your page source, like a Google Maps key. On its own it authorises nothing — every request must also come from a listed address, and that is the check doing the real work. Do not build anything on the assumption that holding the key is enough.
Allowed addresses
Your requests must carry an Origin the organisation has listed. Browsers send this automatically.
Addresses match exactly — no wildcards
Listing https://example.org.za allows precisely that, and refuses https://www.example.org.za, http://example.org.za, any port variation and every subdomain. List each address you actually serve from, including staging.
Because a browser is what supplies the Origin, this API cannot be called from a server. curl holding a valid key gets 403, by design. Server-to-server access would need a secret credential, which this key is not.
Sending the key
| Request | How |
|---|---|
GET | Either Authorization: Bearer pk_live_… or ?key=pk_live_…. The query form avoids a preflight request, so a progress bar loads in one round trip instead of two. |
POST | Authorization: Bearer pk_live_… only. A key in the query string is refused on writes. |
Responses
Success is always wrapped in data; failure in error.
{ "data": { "campaign": { … } } }
{ "error": { "code": "origin_not_allowed", "message": "…" } }Branch on code, never on message. Codes will not be renamed or removed within /api/v1; wording may change at any time. New fields may be added to responses, so ignore ones you do not recognise.
If your address is not listed, you cannot read the error
Error bodies are only readable from an allowed address. From anywhere else your browser reports a generic network or CORS failure with no detail — deliberately, so that an unknown site cannot use the responses to work out whether a key is real. The one exception is 429, which is readable by anyone so you can tell throttling apart from misconfiguration. If you are getting an opaque failure and no body, check the allowed list first.
Error codes
| Code | Meaning |
|---|---|
key_missing | No key was sent, or it was sent in a way this endpoint does not accept. |
key_invalid | The key is unknown — or the organisation has switched the integration off. The two are deliberately indistinguishable. |
origin_not_allowed | Your address is not on the organisation's list, or you sent no Origin at all. |
donations_disabled | The organisation is not accepting donations at the moment. |
rate_limited | Too many requests. Honour the Retry-After header. |
invalid_request | One or more fields are wrong. Carries a fields object naming each. |
invalid_json | The request body was not valid JSON. |
bot_check_failed | The challenge token was missing, already used, or solved on an unregistered hostname. |
proof_of_payment_required | A donation needs a pop_claim unless it is an invoice request. |
file_type_not_allowed | Proof of payment must be JPG, PNG, WebP or PDF. |
file_too_large | Proof of payment must be under 10MB. |
campaign_not_found | No active campaign with that address belongs to this organisation. |
internal_error | Something failed on our side. Safe to retry. |
Reading campaigns
GET https://hopely.co.za/api/v1/campaigns?key=pk_live_…
GET https://hopely.co.za/api/v1/campaigns/winter-drive?key=pk_live_…
GET https://hopely.co.za/api/v1/organization?key=pk_live_…Campaigns are addressed by their slug, and only active ones are returned. Each carries a stats object:
"stats": {
"raised": 12500, // rands, approved donations only
"goal": 50000,
"progress": 25, // percent, or null when there is no goal
"donor_count": 31, // distinct donors
"donation_count": 38, // individual gifts
"pending_count": 2, // submissions awaiting review — NOT money
"days_left": 12 // null when the campaign has no end date
}What these numbers mean, before you put them on a page
raisedcounts donations an administrator has approved, and counts money only — gifts of goods are recorded separately and are not included. It will therefore be lower than the total value an organisation has received, and it matches what the organisation sees on its own Hopely campaign page.
pending_count is a count of submissions nobody has reviewed yet. Anyone who can reach the form can add to it, so never show it as an amount and never add it to raised. “2 gifts awaiting confirmation” is a fair use of it; anything with a currency symbol is not.
donor_count and donation_count differ when someone gives more than once. Label them accordingly.
Submitting a donation
Two steps. Upload the proof of payment first, then send the claim you get back — there is no file path to pass around.
// 1. Upload the proof of payment
const form = new FormData()
form.append('file', file)
const upload = await fetch('https://hopely.co.za/api/v1/uploads/proof-of-payment', {
method: 'POST',
headers: { Authorization: 'Bearer ' + KEY },
body: form,
})
const { data: { pop_claim } } = await upload.json()
// 2. Submit the donation
const res = await fetch('https://hopely.co.za/api/v1/donations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + KEY,
},
body: JSON.stringify({
turnstileToken, // from the challenge widget
donor_name: 'A Donor',
donor_email: 'donor@example.org.za',
amount: 500, // a number, not a string
date: '2026-07-30', // YYYY-MM-DD
payment_method: 'EFT',
pop_claim,
needs_certificate: true,
campaign_slug: 'winter-drive', // optional
}),
})The claim is bound to this organisation and expires after an hour. A successful response is an acknowledgement — id, status and created_at— not the stored record. The donation arrives as a pending submission for an administrator to approve; that approval is what issues the Section 18A certificate.
amount must be a number with at most two decimal places. "500" as a string is rejected rather than converted. Omit pop_claim only when payment_method is "Send Me an Invoice For", which requires donor_email.
Registering a donor
POST https://hopely.co.za/api/v1/donors
Authorization: Bearer pk_live_…
{
"turnstileToken": "…",
"donor_type": "individual", // individual | company | cc | npc
"first_name": "A",
"last_name": "Donor",
"email": "donor@example.org.za",
"id_number": "…", // SA ID, checksum validated
"identification_type": "sa_id", // sa_id | passport | asylum | other
"country_of_issue": "South Africa",
"requires_certificate": true
}Creates a pending registration for review, not a donor record. Organisations must send company_name; individuals must send a name.
Rate limits
| Endpoint | Per visitor | Per organisation |
|---|---|---|
| Reads | 120 a minute | 6,000 a minute |
| Donations | 5 per 10 minutes | 60 an hour |
| Donor registrations | 3 an hour | 30 an hour |
| Uploads | 10 an hour | 120 an hour |
Exceeding a limit returns 429 with a Retry-After header, in seconds.
When something does not work
| Symptom | Cause |
|---|---|
key_invalid | The key is right but the integration is switched off. Ask the administrator to check Settings → Website integration. A wrong key gives the same answer on purpose. |
| Opaque CORS error, no response body | Your address is not on the allowed list. The www and non-www forms are different addresses. |
bot_check_failed | Either the token was already used — get a fresh one after every failed attempt — or your hostnames are not registered with our bot-protection provider yet. Email us. |
| It worked, then stopped after a settings change | Changes take up to a minute to take effect. |
proof_of_payment_required | Send the pop_claim from the upload step, not a file name or path. Claims expire after an hour. |
What this API does not do
- Read donor records, donation history or certificates.
- Take card payments. Donations are recorded with proof of payment.
- Work from a server, for the reason given under allowed addresses.