The ServeManager API is a document, not a record

DHSeaDev — Chrome Extensions, Windows Tools, & Idle Games

I build tools on top of ServeManager — the SaaS most process-serving firms run their dispatch on. An analytics desktop app reads from it, an operations assistant writes notes back to it, and a browser sidebar sits beside it while someone works a job.

It is a well-built API. It is also not shaped like the API most people expect, and the difference is not cosmetic: a wrong read produces a blank field instead of an error, and a wrong write produces a record attached to somebody’s real legal service. This is what I wish I had known on day one, written for whoever is about to integrate with it.

What is ServeManager, if you have never touched it?

It is the system of record for service of process: a law firm sends a job, a process server attempts to hand documents to a named person, each attempt is logged with time and GPS, and the outcome eventually becomes a sworn affidavit filed with a court. The domain vocabulary is small and worth learning before the endpoints — job, attempt, recipient, serve type, service status, court case, affidavit.

Auth is HTTP Basic with the API key as the username and an empty password. That is the whole handshake. Almost every 401 I have seen was a library that did not base64-encode the pair itself.

Why does job.status come back undefined?

Because it does not exist. ServeManager speaks JSON:API, where a response is a document rather than a record. Scalars live under data.attributes, relationships are pointers, and everything a pointer points at is parked in a separate top-level included array.

{
  "data": {
    "id": "123",
    "type": "jobs",
    "attributes": { "service_status": "Served" },
    "relationships": {
      "court_case": { "data": { "id": "55", "type": "court_cases" } }
    }
  },
  "included": [
    { "id": "55", "type": "court_cases",
      "attributes": { "case_number": "CV-2026-001" },
      "relationships": { "court": { "data": { "id": "9", "type": "courts" } } } },
    { "id": "9", "type": "courts",
      "attributes": { "name": "Johnson County District Court" } }
  ]
}

The trap is that job.data.service_status does not throw. It returns undefined, flows through your mapper, and ships as an empty column in a report. JSON:API fails quietly by design, which is exactly the property you do not want in a system where the blank column means “we have no idea whether this person was served.”

How do you get the court name off a job?

Three hops: job to court case to court. Each hop is its own lookup in included, and there is no flattened convenience field waiting for you.

const resolve = (included, ref) =>
  ref?.data
    ? included.find(i => i.id === ref.data.id && i.type === ref.data.type) ?? null
    : null;

const courtCase = resolve(included, job.data.relationships.court_case);
const court     = resolve(included, courtCase?.relationships?.court);
const courtName = court?.attributes?.name;   // null-guard every hop

Write that resolver once and reuse it. Hand-rolling a .find() at each call site is where the id-matched-but-wrong-type bugs come from — two different resources can share an id, and matching on id alone will happily hand you a court case when you asked for a court.

One more: if a relationship pointer exists but resolves to null, that is usually not corrupt data. Sideloading is opt-in. The related resource is simply absent from included because the request did not ask for it.

Which job ID do you show a human?

Not the obvious one. A job carries two identifiers: the JSON:API id, which is the internal database key, and servemanager_job_number, which is what a dispatcher sees on their screen and what a client quotes on the phone.

Printing the first one is not a cosmetic bug. It produces a support call where two people are reading different numbers off the same job and neither can tell that they are. I treat the internal id as structurally unprintable — it never enters a display model in the first place, so it cannot leak into a UI later.

This is the same lesson as what counts as one job, one layer down. That writeup is about identity in the CSV export. This is identity in the live API, and they disagree about which field is authoritative.

Why didn’t the client get an email?

Because actions taken through the API do not fire ServeManager’s own system emails. Do the same thing in the web UI and the notification goes out; do it over HTTP and it does not. If your integration is supposed to notify anyone, that is now your job — via webhooks or your own mail path.

Attribution has the same shape. A note written through the API is credited to the account that owns the key, not to the person who clicked the button. There is no per-call acting-user parameter. The workaround I use is unglamorous and honest: the assistant appends its own attribution line to the note body, because the field that should carry it cannot be set.

What do 406, 409 and 403 actually mean here?

These three read like your payload is malformed. Usually it is not.

  • 406 — a POST, PUT or PATCH went out without Content-Type: application/json. Nothing about your body is wrong.
  • 409 — the body was form-encoded rather than JSON, or the type field on the resource is wrong or missing. Every write is wrapped: { "data": { "type": "job", ... } }.
  • 403 — the key is valid and the request is fine; the employee permission level behind that key is not allowed to do this. Assigning a server needs its own permission. Check the role before you start rewriting the request.
  • 422 — the only one of the four that is genuinely about your fields, and the response body names them. Read errors before guessing.

A related quiet one: sending an unsupported enumerated value often does not error at all. It stores, and then the in-app display breaks somewhere you are not looking.

Why can’t you queue a file upload?

Uploads are two steps: you POST metadata, get a signed put_url back, then PUT the bytes to it. That signed URL expires in roughly five minutes, and download URLs behave the same way.

This quietly kills the design most people reach for — collect the documents, batch them, upload on a schedule. By the time the batch runs, every URL in it is dead. Either upload immediately or fetch a fresh URL at the moment of transfer. And never cache a signed URL anywhere, including in a retry queue, because the retry is the exact case where it will have expired.

Why is the webhook payload sometimes an array?

Because events batch. A handler written against the single-event shape will work for weeks and then drop events on the first busy afternoon, which is the worst possible failure schedule — it looks like a load problem rather than a parsing bug.

const events = Array.isArray(req.body) ? req.body : [req.body];
for (const event of events) { /* handle event.type */ }

Events cover jobs, attempts, attachments, affidavits, invoices and notes. Deleting a webhook stops delivery silently, with no warning anywhere — worth an alert of your own if anything depends on it.

What is still unverified, a year in?

One write. Attaching a file to an already-existing job. The documented flow covers uploading during job creation, and the body schema for attaching afterward is something I have never confirmed against a real captured payload.

So that call is gated and does not run against production. I could infer the shape from the create flow, and I would probably be right. “Probably right” is a fine standard for a hobby project and a bad one for a system of legal record, where a malformed write does not throw an exception — it produces a wrong document on a real case, and nobody notices until someone needs it.

The fix is not cleverness, it is capture: attach a file through the web UI with the network tab open, read the actual request, then write code against that. I have not spent the hour. Until I do, the feature stays off, and this paragraph is the record of why.

What outlived the code

  • Sample the real payload before mapping it. A field name from documentation, a client library or a guess is not evidence of the shape on the wire.
  • Never gate logic or UI on a field you have not seen come back at least once.
  • An API that fails silently needs louder code around it than one that throws.
  • Retries have to be idempotent when a duplicated write is a duplicated legal record.
  • Some of the integration is deciding what not to automate. That is the same conclusion I reached about court filings, arrived at from the opposite direction.

The analytics side of this — what happens to the data once it is out — is ServeBoard.