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

RequestHow
GETEither 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.
POSTAuthorization: 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

CodeMeaning
key_missingNo key was sent, or it was sent in a way this endpoint does not accept.
key_invalidThe key is unknown — or the organisation has switched the integration off. The two are deliberately indistinguishable.
origin_not_allowedYour address is not on the organisation's list, or you sent no Origin at all.
donations_disabledThe organisation is not accepting donations at the moment.
rate_limitedToo many requests. Honour the Retry-After header.
invalid_requestOne or more fields are wrong. Carries a fields object naming each.
invalid_jsonThe request body was not valid JSON.
bot_check_failedThe challenge token was missing, already used, or solved on an unregistered hostname.
proof_of_payment_requiredA donation needs a pop_claim unless it is an invoice request.
file_type_not_allowedProof of payment must be JPG, PNG, WebP or PDF.
file_too_largeProof of payment must be under 10MB.
campaign_not_foundNo active campaign with that address belongs to this organisation.
internal_errorSomething 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

EndpointPer visitorPer organisation
Reads120 a minute6,000 a minute
Donations5 per 10 minutes60 an hour
Donor registrations3 an hour30 an hour
Uploads10 an hour120 an hour

Exceeding a limit returns 429 with a Retry-After header, in seconds.

When something does not work

SymptomCause
key_invalidThe 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 bodyYour address is not on the allowed list. The www and non-www forms are different addresses.
bot_check_failedEither 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 changeChanges take up to a minute to take effect.
proof_of_payment_requiredSend 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.