Changelog
Every change to the published API, newest first. M1 acceptance row 7 asks for this to be published on the portal, and section 3 of the API conventions decides what belongs in it: anything a consumer could notice.
The specifications published at M1 are the contract. Within a major version changes are additive, and section 3.1 is the lookup for what counts as breaking.
Unreleased
Changed
- The token grant says what it takes:
grant_typeisclient_credentials, required, and was missing from the schema and every sample, so the request as documented answered400 invalid_request. The page also says how you get a client identifier and secret (issued by Droomwork on request; no endpoint creates one, and the sandbox holds none until one is issued to you) and that there is no refresh token: when a token expires, mint another. Change control: a correction to the accepted document, nothing an existing caller sent stops working. - The portal has its own name,
https://gethr.droomwork.io, and every document's contact address points there. Nothing about a request changes; the sandbox is stillhttps://sandbox.droomwork.io. Change control: ADR-0028, the contact address only.
1.0.0, 18 September 2026
The M1 specifications, accepted by Droomwork on 18 September 2026. From this day they are the contract: what you integrate against keeps working, and a change to any of them is announced here first. The entries below are what changed on the way to acceptance.
Changed
- Signing out of the portal now signs you out completely. The key the Try-it forms mint for you is revoked and forgotten along with your session, so nothing on that browser can act as your organisation afterwards; signing in again, as any account, retires the previous one. And a Try-it key the sandbox no longer knows is replaced rather than sent forever: the first 401 is checked against your key list, a dead key is swapped for a fresh one and the request sent again, and if you are not signed in the page says so instead of showing the 401.
- Every value that comes from somewhere else says where. A parameter or field whose value you get from another call names that call:
streamon the event list is theidof the run or payslip whose history you want, fromPOST /v1/payroll/runsorGET /v1/payroll/payslips, and the same for every filter, every identifier and every enumeration across the eleven documents. Every one also says required or optional, on the reference pages and beside each Try-it field, and every parameter that isn't an identifier carries an example, so the Try-it panel is filled in before you touch it; a date window opens a year either side of today. The code samples send every path and query parameter, in all thirteen kinds: the cURL and direct-HTTP samples put them in the address and say which are required, and each client-library sample passes them the way that library takes them.pnpm checknow refuses a value with no named source, an enumeration whose values aren't named, a parameter without an example, and a sample that leaves a query parameter out. - One key, two headers, said once: a sandbox key goes in
Droomwork-Api-Key, or afterBearerinAuthorization; both are the same credential, and the reference pages, the Authentication guide and the 401 for a missing credential now say so. The direct samples sendDroomwork-Api-Keywith a variable namedDROOMWORK_API_KEY, as the dashboard does; the client-library samples keepBearer, which is what the libraries send. The Java and Go client-library samples set no credential at all and answered 401 as written; they do now. - The portal says when to call the API directly and when to use the client library: on the first page, on Get started, at the top of every reference page, and as a guide of its own, "Direct HTTP or the client library". Same request, same answer either way; the difference is who writes the boilerplate.
pnpm run sweep -- <address>calls every documented operation at that address with a key it registers for itself, and reports which, if any, is not there. Run against the public sandbox on 16 September: 329 operations, none unreachable.- The sandbox keys page, after you register, reads in the order you need it: the key you were just issued (named, so you can find it in the list), how to use it with that key already in the command, then your keys with the new one marked, then a quiet way to make another. Registering issues your first key; you don't have to press anything.
- The sandbox's address, on every document's
serversentry and in every code sample:https://sandbox.droomwork.io. It wasapi.sandbox.droomwork.com, which never resolved. Nothing else about a request changes. A client library you construct without an address still goes to the live server, as it always did; every sample now sets the sandbox explicitly, in all seven languages. - The OAuth token endpoint is named relative to the server you chose, so a client pointed at the sandbox gets its token from the sandbox. It was an absolute live address.
- Every parameter and every field now says what it is and what you do with it: what a value means, which values it can take, what a timestamp's format is, which prefix an identifier starts with, when it's
null. 2,001 descriptions across the eleven documents, on the reference pages and in the client libraries. The recurring fields (id,object,livemode,mocked,data,has_more,created_at) read the same on every record. - The error index's search takes words, and every word has to appear somewhere in a row: type an endpoint the way you'd say it,
payroll runs, or by the name the reference gives it,payroll.runs.create, or a phrase from what to do. A row found through its endpoints shows only the endpoints that match, and says how many it is hiding. The search is in the page's address, soerrors.html?q=payroll+runsis a link you can send. - The developer portal's address, on every document's contact entry:
https://droomwork-portal-staging.vercel.app, where it is published. It wasdevelopers.droomwork.com, which never resolved.
Added
- A direct HTTP sample on every operation in JavaScript, Python, PHP, Java, C# and Go, beside the client-library one: the address, the bearer token, the idempotency key on a write, and the JSON body, using nothing but the language's own HTTP client. On the reference pages, pick "Direct HTTP" or "Client library" once and every panel follows. Direct is what you see until you choose.
- The Python samples spell their payloads in Python. Seven of them carried JSON's
trueinto a call that raisedNameError. - Eight OpenAPI 3.1 documents, one per module, against one shared set of conventions.
- An error list on every operation, drawn from the registry, and a searchable index built from it.
- An example request on every request body and an example response on every success response.
- Code samples on every endpoint in cURL, JavaScript, Python, PHP, Java, C# and Go.
- Webhooks on every module, with signing, a published retry schedule, replay and a test trigger.
- Event schemas for all 67 published events, and internal interfaces for the seven facts that cross a module boundary.
statusanderrorson the problem document, which RFC 9457 defines and section 12.1 already showed.- Forty operations the sandbox already served and no document declared. Most are the lifecycle of a record: an engagement is papered, executed, registered and activated, an assignment is started, arrived at, departed and completed. The collections were documented and their lifecycles were not, so an integrator could read a whole module and not learn the order things happen in.
Idempotency-Keyandmissing_idempotency_keyare enforced rather than only published. Eighty one operations declared both and no service checked either.unsupported_media_type, status 415, for a request body in a content type no endpoint reads. Any operation that takes a body can return it. The per operation error lists do not name it yet, so the index carries it as platform wide alongsideprovider_timeoutandstale_local_copy.payload_too_large, status 413, for a request body over the 1048576 byte limit every service runs with. Any operation that takes a body can return it, and it is carried in the index as platform wide for the same reasonunsupported_media_typeis.
Removed
- Nine sandbox paths that gave one record a second address. An instrument belongs to an engagement and the document reaches it through one, and the sandbox also mounted it flat; the same for obligation envelopes, proofs of service and payslips. None of the nine was ever published, so nothing that was built against the documents is affected, and the flat collection listings that were published stay.
Changed
GET /{module}/eventspaged withstarting_afterno longer skips an event, and can be a moment behind. It ordered by the instant a row was recorded, which Postgres takes at the start of the writing transaction rather than at its commit, so an event whose write began earlier and finished later carried an instant the page had already passed and was never returned. It orders by the writing transaction now, and returns nothing written by a transaction that might still be open, so an event a consumer would have missed is on a later page instead of on none.
A consumer sees two changes. Nothing is skipped, which is the point. And an event can be absent from a page taken immediately after it was created, for as long as some older write is still in flight, where before it would have been there or gone. starting_after still takes an event identifier and is unchanged in shape.
The per stream read is not affected. ?stream=&after={sequence} is exact, its sequence is assigned under a lock and has no gaps, and it remains what section 17.3 tells a consumer replaying to use. See ADR-0019.
request_idon the event envelope is optional. An engagement lapsing on its end date is raised by a schedule and has no request behind it.- The resolution text on
invalid_money_amounthad the wrong worked example. 1234567 kobo is twelve thousand three hundred and forty five naira and sixty seven kobo, which is what theMoneyschema always said.
Fixed
- A body the server could not read answered
internal_errorand status 500. It now answers 415unsupported_media_typefor a content type nothing reads, and 400invalid_requestfor a body that is empty, does not parse, or does not match itsContent-Length. The old answer blamed the platform for a malformed request, and it landed on the first call many integrators make: curl sendsapplication/x-www-form-urlencodedunless it is given a content type. - A body over the size limit answered
internal_errorand status 500, which told a caller to retry a request that will be refused the same way every time. It now answers 413payload_too_large, and the refusal names the limit and the length the request declared, so there is a number to compare against rather than a size to guess at. - Every readiness report now carries
modeandrows. Without them the endpoint said only that it was not ready, which is the one thing a customer already knows. identity.disclosures.reconcileis served. It had been swallowed by the retrieve on the same collection, which answerednot_found: a code it does not publish and a status its document says it cannot return.