Skip to content
← BlogDeveloper

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

  1. 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.

  2. 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.

  3. 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.

  4. 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."

  5. 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.

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 cuid2con_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’s POST and a job’s POST fail the same way, so your error handling doesn’t branch by pattern.
  • DELETE is always a soft archive. A resource’s row gets dateArchived set 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 plain DELETE.
  • State-changing POSTs accept Idempotency-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.