API documentation
With Giift's API you connect your own systems: your website, your accounting, or Zapier and Make. Read and change contacts, record consent, manage campaigns, and get webhooks when something changes.
Getting started
- Create an API key in Account → API. Only an organisation's owner and admins can.
- Choose what the key may do: everything, read only, or forms only (for Miix Donate in WordPress).
- Send the key in the header
Authorization: Bearer giift_sk_…with every request tohttps://api.giift.nl/v1.
curl https://api.giift.nl/v1/contacts?limit=10 \
-H "Authorization: Bearer $GIIFT_API_KEY"A key gives access to your donors' data. Keep it on your server, never in a web page's or app's code, and revoke it in Account → API if it leaks.
Keys and scopes
Every key belongs to one organisation and has a scope:
| Scope | Can |
|---|---|
full | Everything: read and change contacts, campaigns and webhooks. |
read | Read only: fetch contacts, timelines, campaigns and webhooks. |
checkout | Only what a donation form needs: forms, gifts through a form and campaign totals. For Miix Donate in WordPress. |
Everything you do with a contact through the API (view, change, delete) goes into the organisation's log, under the key's name. A request the scope doesn't allow gets 403 forbidden.
Conventions
- Requests and responses are JSON; send
Content-Type: application/json. - Amounts are always whole cents (
goalCents: 500000is €5,000), in euro. - Dates and times are ISO 8601 with a time zone, e.g.
2026-12-31T23:59:00+01:00. Responses are in UTC. - A list returns
data,page,limit,totalPagesandtotalCount. Page with?page=2&limit=100(at most 100). - Each key may make 120 requests a minute. Beyond that you get
429with aRetry-Afterheader in seconds. - Within
/v1things are only added: new fields, endpoints and events. Ignore fields you don't know.
{
"data": [ { "id": 4821, "firstName": "Anna", "...": "..." } ],
"page": 1,
"limit": 50,
"totalPages": 3,
"totalCount": 128
}Errors
An error always has the same shape: a code for your code to act on, a message for people and sometimes details per field.
{
"error": {
"code": "invalid_request",
"message": "The contact is invalid. See details.",
"details": [
{ "path": "email", "message": "invalid_email" },
{ "path": "birthday", "message": "invalid_date" }
]
}
}| Status | Code | Meaning |
|---|---|---|
| 400/422 | invalid_request | The request isn't right; see details. |
| 401 | unauthorized | No key, or an invalid one. |
| 403 | forbidden | The key's scope doesn't allow this. |
| 403 | account_blocked | The organisation is blocked; please contact us. |
| 404 | not_found | Doesn't exist, or belongs to another organisation. |
| 409 | conflict | Clashes with something that exists, like an email address. |
| 429 | rate_limited | Too many requests; wait Retry-After seconds. |
| 500 | server_error | Something went wrong on our side. Please try again later. |
Contacts
A contact is a person or an organisation. A person can be a contact person of an organisation contact (organisationContactId, with jobTitle).
{
"data": {
"id": 4821,
"kind": "person",
"firstName": "Anna",
"lastName": "de Vries",
"organisationName": null,
"jobTitle": null,
"email": "anna@example.nl",
"phone": "06 12345678",
"birthday": "1980-03-31",
"language": "nl",
"street": "Dorpsstraat",
"houseNumber": "12",
"houseNumberAddition": "a",
"postcode": "6811 AB",
"city": "Arnhem",
"country": "NL",
"organisationContactId": null,
"status": "active",
"source": "api",
"tags": ["Vrijwilliger"],
"consents": {
"email": null,
"newsletter": { "granted": true, "at": "2026-10-18T09:12:00.000Z", "source": "api", "text": "Ja, ik wil de nieuwsbrief" },
"phone": null,
"post": null
},
"giving": { "totalCents": 0, "count": 0, "firstAt": null, "lastAt": null },
"deceasedAt": null,
"createdAt": "2026-10-18T09:12:00.000Z",
"updatedAt": "2026-10-18T09:12:00.000Z"
}
}Listing contacts
GET/v1/contacts
| Parameter | Meaning |
|---|---|
q | Searches name, email, phone, city and postcode. |
email | Exactly this email address (case doesn't matter). |
tag | Contacts with this tag (its name). |
consent | With consent for email, newsletter, phone or post. |
kind | person or organisation. |
status | Active and deceased by default; deceased for deceased contacts only. |
updatedSince | Changed since this moment: to keep another system up to date. |
sort | name (default), newest or updated. |
page, limit | Paging; limit is at most 100. |
Contacts in the list come without consents; you get those with a single contact.
GET/v1/contacts/{id}
Creating a contact
POST/v1/contacts
A person needs a first name, last name or email address; an organisation (kind: "organisation") an organisationName. Postcodes are written neatly (6811 AB), email addresses in lower case. With tags (names; new ones are created) and consents you record consent straight away, with the wording it was given with in consentText.
curl https://api.giift.nl/v1/contacts \
-H "Authorization: Bearer $GIIFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Anna",
"lastName": "de Vries",
"email": "anna@example.nl",
"postcode": "6811AB",
"houseNumber": "12",
"tags": ["Vrijwilliger"],
"consents": { "newsletter": true },
"consentText": "Ja, ik wil de nieuwsbrief"
}'If a contact with the email address exists, you get 409 conflict with its id in the message. So look it up first:
const headers = {
Authorization: `Bearer ${process.env.GIIFT_API_KEY}`,
"Content-Type": "application/json",
};
const email = "anna@example.nl";
// Find by email address first; create the contact when there is none.
const found = await fetch(`https://api.giift.nl/v1/contacts?email=${encodeURIComponent(email)}`, { headers });
const { data } = await found.json();
const contact = data[0] ?? (await (await fetch("https://api.giift.nl/v1/contacts", {
method: "POST",
headers,
body: JSON.stringify({ firstName: "Anna", lastName: "de Vries", email }),
})).json()).data;Changing a contact
PATCH/v1/contacts/{id}
Only the fields you send change; null empties a field. tags is the full list: tags not in it are removed. With status you mark a contact deceased or active again.
curl -X PATCH https://api.giift.nl/v1/contacts/4821 \
-H "Authorization: Bearer $GIIFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "city": "Nijmegen", "phone": null, "tags": ["Vrijwilliger", "Gala 2026"] }'Merging
POST/v1/contacts/{id}/merge
With { "mergeId": 4822 } contact 4822 is merged into this one: empty fields are filled in, tags, consents and the timeline move over, and 4822 is deleted.
Deleting
DELETE/v1/contacts/{id}
For a request to be forgotten: the personal data, consents, tags and timeline are deleted. Gift amounts stay, without a name, for the books. The contact returns 404 afterwards.
Consent, notes and timeline
PUT/v1/contacts/{id}/consents
Consent is per channel (email, newsletter, phone, post): true is given, false withdrawn. Channels you don't send stay as they are. Giift keeps every change with its source, date and the text the person saw; at sets an earlier moment.
curl -X PUT https://api.giift.nl/v1/contacts/4821/consents \
-H "Authorization: Bearer $GIIFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "newsletter": false, "text": "Afgemeld via onze website" }'POST/v1/contacts/{id}/notes
A note (note), phone call (call), email (email) or meeting (meeting) on the timeline.
curl https://api.giift.nl/v1/contacts/4821/notes \
-H "Authorization: Bearer $GIIFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "call", "body": "Bedankt voor de gift; wil graag het jaarverslag." }'GET/v1/contacts/{id}/timeline
Everything that happened with the contact, newest first: notes, changes, consent, imports and merges. by is who did it.
Campaigns
A campaign is what a gift is for: a cause with a name, a description for the thank-you email, a goal and a period. Its id starts with cmp_.
GET/v1/campaigns
POST/v1/campaigns
curl https://api.giift.nl/v1/campaigns \
-H "Authorization: Bearer $GIIFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Noodhulp honden",
"description": "Voor honden in nood.",
"goalCents": 500000,
"endsAt": "2026-12-31T23:59:00+01:00",
"publicTotal": true
}'GET/v1/campaigns/{id}
PATCH/v1/campaigns/{id}
Changes the fields you send. archived: true puts a campaign away; list archived ones with ?archived=true.
Showing what's been raised
GET/v1/campaigns/{id}/totals
Raised, number of donors and progress. Without a key, from any website, for campaigns with publicTotal: true; cached for a minute. The amounts grow as gifts come in through Giift.
<p id="raised">…</p>
<script>
fetch("https://api.giift.nl/v1/campaigns/cmp_k7Qx2mPa9rTb/totals")
.then((response) => response.json())
.then(({ data }) => {
const euros = new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" });
document.getElementById("raised").textContent =
`${euros.format(data.raisedCents / 100)} van ${euros.format(data.goalCents / 100)}`;
});
</script>Webhooks
With a webhook Giift sends a message to your system as soon as something happens. Add one in Account → API or through the API.
GET/v1/webhooks
POST/v1/webhooks
curl https://api.giift.nl/v1/webhooks \
-H "Authorization: Bearer $GIIFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.org/hooks/giift",
"events": ["contact.created", "contact.updated", "contact.deleted"]
}'The response holds the secret you use to check that messages come from Giift; through the API you only see it then. The URL must be https and publicly reachable.
DELETE/v1/webhooks/{id}
Events
| Event | When |
|---|---|
contact.created | A contact was created, in Giift, by an import or through the API. |
contact.updated | Details, tags, consent or status changed. |
contact.deleted | A contact was deleted (anonymised) or merged; then mergedInto is included. |
donation.paid | A gift was paid. Comes with gifts in Giift. |
donation.failed | A gift or direct debit failed. Comes with gifts in Giift. |
donation.refunded | A gift was refunded or charged back. Comes with gifts in Giift. |
pledge.created | A recurring gift started. Comes with recurring gifts. |
pledge.stopped | A recurring gift stopped. Comes with recurring gifts. |
{
"id": "evt_Qm3xT9a2LpZk",
"type": "contact.updated",
"createdAt": "2026-10-18T09:15:42.000Z",
"data": {
"contact": { "id": 4821, "firstName": "Anna", "...": "..." }
}
}- Giift sends a
POSTwith the headersGiift-Event,Giift-DeliveryandGiift-Signature. - Answer within 10 seconds with a
2xx. Otherwise Giift tries again, waiting longer each time, for about two days. - The same message can arrive more than once: use
idto spot duplicates. One change can send severalcontact.updatedmessages; each holds the latest state. - Giift switches off a webhook that keeps failing; switch it back on in Account → API.
Checking the signature
Giift-Signature is t=<timestamp>,v1=<signature>: an HMAC-SHA256 of the timestamp, a dot and the body, with your webhook secret. Refuse messages with another signature or a timestamp more than five minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
// In your route: read the body as text, exactly as it arrived.
export function isFromGiift(body, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.v1 || age > 300) return false; // older than 5 minutes: refuse
const expected = createHmac("sha256", secret).update(`${parts.t}.${body}`).digest("hex");
return expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// isFromGiift(rawBody, request.headers["giift-signature"], process.env.GIIFT_WEBHOOK_SECRET)Privacy
- Your organisation is responsible for its contacts' data; Giift processes it for you. Only fetch what you need.
- Every view, change and deletion through the API is in the log at Account → Privacy and log.
- Webhook messages hold personal data: make sure the receiver stores them as carefully. Giift deletes sent messages after 30 days.
- Handle an access request or a request to be forgotten with
GET /v1/contacts/{id}andDELETE /v1/contacts/{id}.
OpenAPI
The full description of the API is at https://api.giift.nl/v1/openapi.json as OpenAPI 3.1, for tools like Postman and Insomnia and to generate code.
Changes
| Date | Change |
|---|---|
| 2026-10-18 | First version: keys with scopes, contacts, consent, notes, timeline, campaigns and webhooks. |