The five patterns behind every Norbelys API endpoint
Every Norbelys REST endpoint is one of five patterns: resource, relationship, view, action, or job. Learn the five and you can predict the whole API.
By Gabriel Lara, Developer Relations, Norbelys
Founder-reviewed ·How we research and correct articles
Most APIs grow one endpoint at a time until nobody can predict the next one
without reading the reference. Ours doesn’t work that way on purpose. Every
route under https://api.norbelys.com/v1 is an instance of exactly one of
five shapes, and once you’ve seen all five, you can guess most of the surface
correctly before you ever open the OpenAPI spec.
This isn’t a marketing simplification. It’s the literal design rule the API was audited against: is this a resource (a noun), a genuine action (a verb that doesn’t fit CRUD), or is it secretly the same idea as something else wearing a different shape? Endpoints that failed that test got renamed or merged. What’s left is five patterns, applied everywhere, with no exceptions.
The five patterns
RESOURCE
GET /v1/<plural> to list, POST to create. GET/PATCH/DELETE /v1/<plural>/:id for a single record. This is standard REST, and it covers most of the API.
RELATIONSHIP
A many-to-many link, exposed as its own sub-collection: GET/POST/DELETE on /v1/<plural>/:id/<related>[/:relatedId]. The join itself is a resource, not a bespoke attach/detach verb.
VIEW
A derived read of a resource or collection, requested with ?view=. The underlying record doesn't change shape — you're asking for a different projection of it.
ACTION
POST /v1/<plural>/:id:<verb> for a real verb that isn't CRUD — pause, verify, test, erase. The colon marks it visually as "this does something" rather than "this is a sub-collection."
JOB
POST /v1/jobs for anything that processes a list asynchronously — import, bulk work, mass enrollment. You create the job, then poll it; the same shape covers every bulk operation.
That’s the entire vocabulary. No sixth pattern exists. If you’re trying to figure out how to do something with the API and it isn’t obviously CRUD, it’s almost certainly a view, an action, or a job — not a new kind of endpoint.
1. RESOURCE — the default shape
Most of what the API models is a straightforward resource: people, senders,
domains, programs, suppressions. GET lists it, POST creates it, and the
:id routes read, update, or archive a single record.
curl https://api.norbelys.com/v1/people \
-H "Authorization: Bearer ak_live_..." \
-H "Content-Type: application/json"
curl https://api.norbelys.com/v1/people \
-X POST \
-H "Authorization: Bearer ak_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: create-person-4f2a" \
-d '{"email":"jane@acme.com","givenName":"Jane","familyName":"Doe","affiliation":"Acme"}'
DELETE on a resource is always a soft archive (dateArchived gets set,
the row disappears from default lists but isn’t gone). The field names —
givenName, affiliation — aren’t arbitrary; they’re Schema.org vocabulary,
which has its own reasoning.
2. RELATIONSHIP — a link is a resource too
A many-to-many association gets the same GET/POST/DELETE treatment as
any other resource, nested under its owner. There’s no attach or detach
verb anywhere in the API — adding to a collection is a POST, removing from
it is a DELETE, which is exactly what those verbs already mean.
curl https://api.norbelys.com/v1/programs/prg_9x2k/senders \
-H "Authorization: Bearer ak_live_..."
curl https://api.norbelys.com/v1/programs/prg_9x2k/senders \
-X POST \
-H "Authorization: Bearer ak_live_..." \
-H "Content-Type: application/json" \
-d '{"senderId":"snd_7qmz"}'
The join row is a real database record (program_sender), not a computed
side effect — that’s what makes it a resource instead of a flag on the
program.
3. VIEW — the same resource, a different projection
A timeline used to be a special sub-route only contacts had. But senders have
activity too, and so do programs and enrollments — a timeline isn’t specific
to one entity, it’s a derived read that applies to any entity. So instead of
inventing a new route per entity, every resource takes ?view=:
curl "https://api.norbelys.com/v1/programs/prg_9x2k?view=timeline" \
-H "Authorization: Bearer ak_live_..."
No ?view= returns the plain resource. ?view=timeline returns the
read-time merge of the resource’s own activity plus its analytics events. The
same query parameter works on people, senders, programs, and enrollments —
one pattern, every entity, instead of a differently-shaped endpoint per case.
4. ACTION — the few real verbs
After resources, relationships, and views absorb almost everything, what’s
left is a short, closed list of genuine actions — verbs that don’t fit CRUD
because they don’t create, read, update, or delete a record so much as do
something to it. Every one of them follows POST /v1/<resource>/:id:<verb>:
curl https://api.norbelys.com/v1/programs/prg_9x2k:pause \
-X POST \
-H "Authorization: Bearer ak_live_..." \
-H "Idempotency-Key: pause-prg_9x2k-2026-07-14"
The colon isn’t decorative. /senders/:id/tags is a relationship — a noun,
a collection you can list. /senders/:id:test is an action — a verb, a thing
that happens once. Reading the colon as “this does something” is a habit both
humans and LLM agents pick up fast. The whole verb vocabulary is small:
pause/resume on programs, verify on domains, test on senders,
winner on steps, erase on people (the one true hard-delete, audited), and
token as the one action with no owning resource (it mints the analytics
credential).
5. JOB — bulk work is a resource, not a fire-and-forget call
Anything that has to chew through a list asynchronously — a CSV import, a mass campaign enrollment — used to be modeled as two different one-off endpoints with two different progress shapes. The fix was to notice that a bulk operation is a thing that exists over time with progress, which makes it a resource, not a verb:
curl https://api.norbelys.com/v1/jobs \
-X POST \
-H "Authorization: Bearer ak_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: import-oct-list-01" \
-d '{"type":"contact_import","sourceR2Key":"imports/org_4k2/list.csv"}'
# → 202 { "id": "job_p3m1", "type": "contact_import", "status": "processing", "total": 4000, "done": 0, "failed": 0 }
curl https://api.norbelys.com/v1/jobs/job_p3m1 \
-H "Authorization: Bearer ak_live_..."
You create a job, then poll it. That’s it — one progress shape, whether it’s a CSV import or a full-audience campaign activation, instead of a different response format for every kind of bulk work.
The conventions that hold all five together
The five patterns tell you the shape of an endpoint. A small set of conventions, applied identically across all five, tell you what to expect from the details — and once you know them, you stop needing to check the docs for things like “how do I paginate this” every time.
- IDs are prefixed cuid2 —
con_8h3k,snd_7qmz,prg_9x2k— opaque, crypto-seeded, and deliberately not time-sortable. The prefix tells you the resource type at a glance in a log line; the rest tells you nothing else, which is the point. - Pagination is cursor-based,
?cursor=&limit=, never offset-based. The cursor is an opaque keyset over(createdAt, id), so lists are always ordered by when something happened, never by an ID that isn’t sortable anyway. - Errors are one shape everywhere:
{ error: { code, message, field? } }paired with a standard HTTP status. A resource’sPOSTand a job’sPOSTfail the same way, so your error handling doesn’t branch by pattern. DELETEis always a soft archive. A resource’s row getsdateArchivedset and drops out of default list results; it isn’t gone. The one true hard-delete — erasing a person’s PII for good — is a dedicated, audited action (people:erase), never a plainDELETE.- State-changing
POSTs acceptIdempotency-Key. Every pattern that can send, enqueue, or mutate — resource creation, actions, jobs — honors the same header for safe retries.
None of these are pattern-specific. Learn them once and they apply whether you’re listing people, pausing a program, or polling a bulk-import job.
Why five patterns, and not forty endpoints
The point of collapsing the surface to five shapes isn’t tidiness for its own
sake — it’s that a developer, or an AI agent, only has to learn five rules to
predict the entire API instead of memorizing forty routes. Ask “how do I get
a sender’s activity history?” and the pattern answers itself: a derived read
of a resource is always ?view=, so it’s GET /v1/senders/:id?view=timeline.
No special sub-route to look up.
That predictability is the actual reason the same OpenAPI spec can drive four generated SDKs, a CLI, and an MCP server without any of them needing hand-written glue: the pattern space is small enough that a code generator covers it completely, and a human reading the spec cold recognizes the shape before reading the description.
Whichever client or language you call it from, the five patterns are
what you’re actually calling — the REST API, the CLI, the SDKs, and the
MCP server all resolve to the same resource/relationship/view/action/job
surface, not a different one per client.
Every state-changing POST in all five patterns also accepts an
Idempotency-Key header, which matters more than it looks —
here’s why.
Frequently asked questions
Is there an endpoint that doesn't fit any of the five patterns?
No. The five patterns were arrived at by auditing the full surface and folding every exception into one of them — a special timeline route became the VIEW pattern, tag attach/detach became RELATIONSHIP, CSV import and bulk send became one JOB type. Nothing was left as a one-off.
Why use a colon for actions instead of a sub-path?
The colon visually separates a genuine action from a sub-resource. A slash after an ID reads as a collection you can list, like /senders/:id/tags. A colon reads as a verb happening once, like /senders/:id:test. That distinction helps both human readers and AI agents predict behavior from the URL alone.
How do I know which query patterns support ?view=?
Any resource that has a derived read supports it, and the OpenAPI spec documents the valid view values per resource. The common one is view=timeline, available on people, senders, programs, and enrollments.
What happens if I call a job endpoint for something small, like creating one person?
Nothing stops you from using the resource pattern instead — POST /v1/people for one record is the right call. The job pattern exists specifically for operations that process a list asynchronously; a single-record create has no progress to poll, so it stays a plain resource creation.