Documentation · guide 6 of 10
Integrations: API, webhooks, automation services and sign-in providers
This is docs/06-integrations.md, one of the ten guides that ship inside the AlumDeck zip under docs/, as written for release 2.0.0. The same guide opens inside the admin, from the ? link in a screen's header or from System, Help.
AlumDeck can work with the other software your association uses. There are four parts. Three are module switches on Settings > Modules, all off on a new install - Developer API, Outbound webhooks, and Sign in with Google, Microsoft, LinkedIn, Facebook or Apple; the automation services work once the first two are on:
- Developer API - other software reads and changes your data with an API token.
- Outbound webhooks - AlumDeck tells other software the moment something happens.
- Automation services - Zapier, Make and Power Automate, built on the two above.
- Sign in with Google, Microsoft, LinkedIn, Facebook or Apple - for members.
Nothing here sends your data anywhere until an administrator sets it up.
Two sections at the end are for administrators of these connections: AI assistants that connect by signing in (OAuth), and signing out everywhere after a SAML sign-in (single logout).
The REST API
The API lives at https://your-site/api/v1. A full machine-readable description (OpenAPI 3.1) is at https://your-site/api/v1/openapi.json; tools that read OpenAPI can import it.
API tokens
Every call needs a personal access token. Make one under System > API tokens:
- Press New token and name it after where it will be used ("Zapier - new gifts").
- Tick what it may do. You are offered only what you can do in the admin yourself: "Read members" needs at least View on the Members area, "Add gifts" needs Edit on Giving.
- Choose when it expires (Settings > Integrations sets the longest allowed, 365 days unless changed).
- Copy the token (it starts
adk_). It is shown once; only a fingerprint of it is stored.
Send it with every request:
curl -H "Authorization: Bearer adk_..." https://your-site/api/v1/members?per_page=5A token only ever does what its owner can do now: take an area away from a person and their tokens lose it too. Revoke a token on the same screen; it stops at once, together with any webhook subscriptions it made. Full administrators see and can revoke everyone's tokens.
Scopes: members:read, members:write, events:read, events:write, registrations:read, registrations:write, gifts:read, gifts:write, appeals:read, appeals:write, memberships:read, memberships:write, groups:read, groups:write, posts:read, posts:write and webhooks:manage (for automation services that subscribe to webhooks).
What you can read and change
| Address | Read | Write |
|---|---|---|
/members | member records | change contact and profile details (PATCH) |
/events | events (never templates) | add (POST), change (PATCH) |
/registrations | event registrations | add (POST), cancel (POST /{id}/cancel; refunds stay in the admin) |
/gifts | recorded gifts | record a gift (POST) |
/appeals | appeals with goal and amount raised | add (POST), change (PATCH) |
/memberships | dues memberships and their state | add (POST), renew (POST /{id}/renew) |
/groups | groups with member counts | add a member (POST /{id}/members), take one out (DELETE /{id}/members/{member_id}) |
/posts | news posts, stories, spotlights | add (POST), change (PATCH) |
Each address takes /{id} for one record. No record is deleted through the API; taking a member out of a group is the one removal it makes.
Privacy. Member records follow each member's own privacy choices. A detail a member keeps to their class year or to themselves is null, and its name is listed in private_fields. Filtering follows the same rule, so a hidden city cannot be found by filtering for it. Donor tax numbers, donor addresses and private notes are never in the API.
Lists take:
filter[name]=value- for examplefilter[batch_year]=2010orfilter[updated_since]=2026-01-01T00:00:00Z(every list hasupdated_sinceandcreated_since; the rest are listed in openapi.json);sort=-created_at,name- a leading minus sorts newest or Z first;fields=id,name,email- only these fields;pageandper_page(up to 100). The answer hasmeta(page, total) andlinks(next, prev) and aLinkheader.
An unknown filter, sort or field is refused with a 400 that lists what is allowed.
Money is in whole minor units with its currency and an exact decimal string: {"amount_minor": 2500, "currency": "USD", "amount": "25.00"}. Send amount_minor when you record a gift.
Times are UTC in ISO 8601 (2026-11-01T09:00:00Z). A time you send without an offset is read in the site's timezone (Settings > General).
Caching. Reads carry an ETag. Send it back as If-None-Match and an unchanged record answers 304 Not Modified. Send it as If-Match with a PATCH and the change is made only if nobody changed the record since you read it (else 412).
Writes accept only the fields listed for them; anything else is refused with a 422 that names it. A gift recorded through the API gets the next receipt number and is recorded by the token's owner, as on the Gifts screen. Every write call, allowed or not, is in the audit log with the token's name.
Errors always look like this:
{"error": {"status": 403, "code": "insufficient_scope", "message": "...", "details": {"required_scope": "gifts:write"}}}Codes: bad_request, unauthenticated, token_expired, token_revoked, forbidden, insufficient_scope, not_found, method_not_allowed, conflict, precondition_failed, validation_failed, rate_limited, server_error, unavailable.
Limits. Each token may make 120 requests a minute (Settings > Integrations). Every answer has X-RateLimit-Limit and X-RateLimit-Remaining; over the limit you get 429 with Retry-After. Too many wrong tokens from one address are refused for a minute. While a licence has lapsed the API pauses, like every other sign-in.
Webhooks
A webhook is a message AlumDeck sends to another system's address when something happens. Switch on "Outbound webhooks" under Settings > Modules, then add an address under System > Webhooks (full administrators only): a name, the address the other system gave you, and the events to send.
Events
| Event | When |
|---|---|
member.created | a member record is created |
member.updated | a member's details change (changed lists the fields; changes to details the member keeps private are not sent) |
member.erased | a member's personal data was erased on request - only the id is sent; remove them from your system too |
gift.recorded | a gift is recorded, online or by hand |
payment.paid | a payment is completed (a gift, dues, a ticket) |
payment.refunded | a payment is refunded in full or in part |
registration.created | an event registration is started or made |
registration.confirmed | an event registration is confirmed |
registration.cancelled | an event registration is cancelled |
membership.activated | a dues membership starts |
membership.renewed | a dues membership now runs longer |
membership.cancelled | a dues membership is ended |
event.published | an event is published |
post.published | a news post is published |
group.member_joined | a member joins a group |
What is sent
Each event is a JSON POST:
{
"id": "5b0e2f1c-...",
"type": "gift.recorded",
"created_at": "2026-09-26T18:30:00Z",
"api_version": "v1",
"data": { ... the same record the API returns ... }
}with these headers: X-AlumDeck-Event (the type), X-AlumDeck-Event-Id (the same for every copy of one event - use it to ignore repeats), X-AlumDeck-Delivery, X-AlumDeck-Timestamp and X-AlumDeck-Signature.
Checking the signature
Each webhook has its own signing secret (Signing secret on its page; it starts whsec_). The signature header looks like t=1767225600,v1=5f0c.... To check it, compute HMAC-SHA256 of the timestamp, a full stop and the raw body with the secret, and compare it with each v1 value; also refuse a timestamp more than five minutes from your own clock.
PHP:
$header = $_SERVER['HTTP_X_ALUMDECK_SIGNATURE'];
$body = file_get_contents('php://input');
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
if ($key === 't') { $t = (int) $value; } elseif ($key === 'v1') { $signatures[] = $value; }
}
$expected = hash_hmac('sha256', $t.'.'.$body, $secret);
$ok = $t !== null && abs(time() - $t) <= 300
&& array_filter($signatures, fn ($s) => hash_equals($expected, $s)) !== [];Python:
import hmac, hashlib, time
parts = [p.split('=', 1) for p in header.split(',')]
t = next(v for k, v in parts if k == 't')
expected = hmac.new(secret.encode(), (t + '.').encode() + raw_body, hashlib.sha256).hexdigest()
ok = abs(time.time() - int(t)) <= 300 and any(hmac.compare_digest(expected, v) for k, v in parts if k == 'v1')Node.js:
const crypto = require('crypto');
const parts = header.split(',').map(p => p.split('='));
const t = parts.find(([k]) => k === 't')[1];
const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
const ok = Math.abs(Date.now() / 1000 - Number(t)) <= 300 &&
parts.some(([k, v]) => k === 'v1' && v.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)));Rotate secret makes a new one. For the next 24 hours every event is signed with both (two v1 values), so you can give the new secret to the other system without losing events.
Delivery, retries and switching off
The scheduler sends waiting events every minute (it needs the usual cron line). An answer in the 200s within 10 seconds counts as delivered. Anything else is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours - eight attempts - and then marked failed. Redirects are not followed.
After 20 failed attempts in a row (Settings > Integrations) the webhook is switched off, the events waiting for it are marked failed and the administrators are emailed. Fix the other end, press Send a test, then switch "Send events" back on.
Each webhook's page has a Delivery log: every event sent, the answer, the attempts and the next try. Contents shows exactly what was sent; Replay sends it again as a new delivery with the same event id.
Only https addresses on the public internet are accepted: an address that leads into a private network (the server's own, the hosting company's) is refused when it is saved and again before every send.
Sent events and the delivery log are kept for 30 days (Settings > Integrations), then deleted: they hold member details. When a member's data is erased, their events go at once and member.erased is sent.
Zapier, Make and Power Automate
These services connect AlumDeck to thousands of other apps. They use the API and webhooks above, so switch on both modules and make an API token first (System > API tokens). For a service that listens for events, tick webhooks:manage and the read scope of the records it needs (for example "Read gifts" for gift.recorded); for one that adds or changes records, tick the matching write scope.
There are three ways for a service to hear about events:
- Webhook to the service. Most services give you an address to receive events. Add it under System > Webhooks and choose the events.
- REST hooks. A service can subscribe itself when you turn a workflow on and unsubscribe when you turn it off:
POST /api/v1/hookswith{"target_url": "https://...", "event": "gift.recorded"}(urlis accepted too) answers with the subscription'sidand its signingsecret;DELETE /api/v1/hooks/{id}removes it;GET /api/v1/hookslists the token's subscriptions. They appear under System > Webhooks marked "API", and stop when the token is revoked. - Polling.
GET /api/v1/triggers/{event}returns the latest events of one type, newest first; each has a uniqueidto skip what was already seen (?limit=up to 100).GET /api/v1/triggerslists the event types and whether your token may read each.
GET /api/v1/triggers/{event}/sample returns one event to set a workflow up with: the latest real one, or one built from your latest record of that kind, or a made-up one of the right shape when there is none yet (then "sample": true). Polling only sees events recorded while the Developer API module is on.
To test a token, call GET /api/v1/me: it names the token, its owner and its scopes.
Zapier
- Trigger by webhook: add a "Webhooks by Zapier" step, "Catch Hook", copy its address and add it under System > Webhooks with the events you want. Send a test from there so Zapier sees the fields.
- Trigger by polling: "Webhooks by Zapier", "Retrieve Poll", address
https://your-site/api/v1/triggers/gift.recorded, headerAuthorization: Bearer adk_..., andidas the deduplication key. - Actions: "Webhooks by Zapier", "Custom Request", method POST or PATCH, address such as
https://your-site/api/v1/gifts, the same Authorization header and a JSON body. - If you build a private Zapier app, use REST hooks: subscribe with
POST /api/v1/hooks, unsubscribe withDELETE /api/v1/hooks/{id}, and the sample address above for "perform list".
Make
- Trigger by webhook: a "Webhooks", "Custom webhook" module gives an address; add it under System > Webhooks, then "Redetermine data structure" and send a test.
- Trigger by polling: schedule an "HTTP", "Make a request" module on
/api/v1/triggers/{event}with the Authorization header, and skip ids already processed. - Actions: the "HTTP" module with the Authorization header and a JSON body.
Power Automate
- Trigger by webhook: the "When a HTTP request is received" trigger gives an address (Power Automate may count it as a premium connector). Add the address under System > Webhooks. Paste the output of
/api/v1/triggers/{event}/sampleinto "Use sample payload to generate schema". - Trigger by polling: a "Recurrence" trigger followed by an "HTTP" action on
/api/v1/triggers/{event}, keeping the ids already handled. - Actions: the "HTTP" action with the Authorization header and a JSON body.
Automation services cannot usually check signatures. Treat the address they give you as a secret, and remove the webhook when a workflow is no longer used.
Sign in with Google, Microsoft, LinkedIn, Facebook or Apple
Members can sign in with an account they already have. You use your OWN app at each provider, so no third party sits between your members and your site.
- Switch on "Sign in with Google, Microsoft, LinkedIn, Facebook or Apple" under Settings > Modules.
- Open Settings > Sign-in providers. Each provider's section shows the redirect address to register with it, then asks for the app's details. Secrets are stored encrypted and never shown again; a blank box keeps the saved one.
- Switch on the providers you set up. Their buttons appear on the member sign-in page.
Where to make the app:
- Google: Google Cloud console > APIs and services > Credentials > OAuth client ID, type "Web application".
- Microsoft: Microsoft Entra admin centre > App registrations, platform "Web", then a client secret. "Who may sign in" takes
common,consumers,organizationsor your directory (tenant) ID. Work and school addresses count as verified only if you add the optional ID token claimxms_edov; personal Microsoft accounts are verified by Microsoft. - LinkedIn: create an app in the LinkedIn developer portal and add "Sign In with LinkedIn using OpenID Connect".
- Facebook: Meta for Developers > create an app with Facebook Login. Facebook does not say whether an address is verified, so members link Facebook from their profile before they can sign in with it.
- Apple: a Services ID with Sign in with Apple, and a key for it; paste the Services ID, your Team ID, the key's ID and the contents of the .p8 key file.
How it behaves:
- Nobody gets a new account this way. The first time, the provider must confirm an address that belongs to an active member; the provider account is then linked to that member and the member is emailed. After that the link is used, whatever address the provider has.
- Staff accounts sign in with their password unless "Staff accounts may sign in this way too" is on; the admin panel still asks for their two-factor code.
- Every sign-in uses a one-time state tied to the browser, a nonce and (where the provider supports it) PKCE. Only the provider's id for the person and the address it gave are kept - no provider passwords or tokens.
- Members link and unlink providers under My profile > Sign-in methods. Their password keeps working either way. "Download my data" lists the links; erasing a member removes them.
AI assistants that connect by signing in (OAuth)
Some AI assistants - claude.ai is one - connect to the MCP server (see AI assistants (MCP)) by asking your site for access, instead of being given an API token. Your site is then both the MCP server and the sign-in service the assistant trusts (OAuth 2.1).
Switching it on
- Under Settings > Modules switch on MCP server for AI assistants, Developer API and AI assistants connect by signing in (OAuth).
- In the assistant, add a connector with the address
https://your-site/mcp. Leave any "client ID" and "client secret" boxes empty: the assistant registers itself. - The assistant opens the consent page on your site. Sign in there as a full administrator.
The assistant finds everything else on its own: the 401 answer of /mcp names /.well-known/oauth-protected-resource/mcp (RFC 9728), which names your site as the sign-in service, described at /.well-known/oauth-authorization-server (RFC 8414). Registration is at /oauth/register, sign-in at /oauth/authorize, tokens at /oauth/token and giving tokens back at /oauth/revoke.
AlumDeck in a folder (https://example.org/alumni rather than a domain of its own): the standard addresses then lie at the root of the domain, outside AlumDeck's folder - https://example.org/.well-known/oauth-authorization-server/alumni and https://example.org/.well-known/oauth-protected-resource/alumni/mcp. Send them on to AlumDeck with one rule near the top of the .htaccess file at the root of the domain (the site there, not AlumDeck's own file), writing your folder's name for both alumni:
RewriteEngine On
RewriteRule ^\.well-known/(oauth-authorization-server|oauth-protected-resource)/alumni(/.*)?$ /alumni/.well-known/$1/alumni$2 [R=302,L]On Nginx the same rule is:
location ~ ^/\.well-known/(oauth-authorization-server|oauth-protected-resource)/alumni(/.*)?$ { return 302 /alumni/.well-known/$1/alumni$2; }To check it, open https://example.org/.well-known/oauth-authorization-server/alumni while the module is on: a short JSON document whose issuer is https://example.org/alumni should appear. AlumDeck answers only the addresses that name its own folder. A site on a domain of its own needs no rule.
The consent page
- Only a signed-in full administrator, past their two-step sign-in, can allow an assistant. Other staff see a refusal.
- The page names the assistant, what it asks to read (the scopes), and the site it returns to. Allow or Deny; both are in the audit log.
- An assistant can only read, and only what the MCP tools read:
members:read,events:read,gifts:readandposts:read. It never sees more than the administrator who allowed it can see now: a scope that administrator does not have is listed as switched off and not given, and taking an area away from them later narrows the connection too. - Access tokens last 60 minutes. Refresh tokens last 30 days and are replaced each time they are used; a refresh token used twice ends the whole connection, because it was probably copied. A sign-in code lasts 5 minutes and works once. These tokens work on
/mcponly, never on/api/v1. - Registration is limited: 10 an hour from one address, at most 50 assistants waiting for their first consent, and return addresses must be
https(or the assistant's own computer). An assistant that is never allowed is removed by the nightly clean-up after a day.
Seeing and ending connections
- Each connection is a row under System > API tokens, named after the assistant and owned by the administrator who allowed it. Revoke ends it at once; the assistant has to ask again.
- Every request it makes is listed under System > AI assistant log, with the connection's name.
- The audit log has every registration, consent, new token, and any reused code or refresh token.
- Switching the module off stops every connected assistant at once and its addresses answer Not Found. The connections are kept.
Single logout for SAML sign-in
With a SAML provider under Settings > Single sign-on (see Single sign-on), signing out can reach both sides:
- Signing out here - the member area's Sign out, or Sign out in the admin panel - also signs the person out at the identity provider, when they signed in through it. The browser goes to the provider with a signed sign-out request, and the provider sends it back to the home page. The person is signed out here at once, whatever the provider answers.
- Signing out there - the identity provider can send a sign-out request for a person, and this site signs that person out when it names their session here.
What the identity provider needs
- Our single logout address, for both HTTP-Redirect and HTTP-POST. It is listed under "For the identity provider" on the provider's page (
https://your-site/connect/sso/<address name>/slo, with a Copy button), and it is in our metadata, so a provider that reads our metadata has it. - Our certificate. Every sign-out message this site sends is signed with its own key (RSA-SHA256), whatever the switches below say; the provider checks it with the certificate in our metadata. After Replace our signing key (the list's row menu) the provider must read our metadata again, or single logout stops working.
- Its own sign-out address in Sign-out address (HTTP-Redirect, optional). Pasting its metadata fills it in. Sign-out answer address is needed only if the provider wants answers at another address. Without a sign-out address, signing out here signs out here only.
- Its signing certificate in Signing certificates: every sign-out message it sends must be signed (in the query for HTTP-Redirect, inside the message for HTTP-POST). Unsigned ones are refused.
The two switches
- Sign our sign-in requests - for sign-in only. Turn it on when the provider wants signed sign-in requests; it reads our certificate from our metadata. Sign-out messages are always signed.
- Accept encrypted assertions only - for sign-in answers: on, an answer the provider did not encrypt with our certificate is refused. A provider may also encrypt the person's identifier in its sign-out requests; those are read with the same key, and Accept AES-GCM encryption only applies to them too.
What is checked
Each sign-out message must come from the provider's entity ID, be addressed to our single logout address, be recent (15 minutes, with 3 minutes for clocks) and be used once; our own request waits 10 minutes for its answer, which must answer exactly that request. A request the provider sends by HTTP-POST comes without the browser's cookie, so the browser is sent on once more to finish the sign-out where the session is. Every sign-out and every refusal is in the audit log.
Guide 6 of 10
Keep reading
The ten guides ship together in the zip, in this order.
Stuck on something this guide does not cover?
Write to the people who build AlumDeck through the contact form. We aim to reply within one working day, Monday to Friday: a target, not a guarantee.