Skip to main content
workmylist

← Help centre

API

For running your own automation against your account. If you built the list logic yourself — the plays, the ranking, the openers — this is how you hand us the list without sitting in the dashboard, start the run, and read back what the compliance gate decided.

Three endpoints, one auth header. The base URL is https://workmylist.com. Everything is JSON in and JSON out, and every error has the same shape: {"ok": false, "error": "..."}with a status that means what it says. 401 is “we do not know who you are”, 403 is “we do, and this key may not”, 429 is “you may, but not this fast”.

1. Get a key

Mint one under API keys in your account. The secret starts with wml_live_ and is shown once, at creation. We store a SHA-256 of it, so there is nothing to recover if you lose it: revoke and mint another.

Scope each key to what it actually needs:

  • contacts:read — list what is on file.
  • contacts:write — hand over a list.
  • campaigns:read — poll a run.
  • campaigns:initiate — start a run. This is the one that makes a phone ring, and it is off by default on a new key. Leave it off until you have watched a list land correctly.
  • audit:read — read the compliance trail. Its own scope rather than part of campaigns:read, because the trail holds the history of every contact in the account and a key minted to poll run outcomes should not silently carry that.
Authorization: Bearer wml_live_...

2. Hand over a list

POST /api/v1/contacts, scope contacts:write. Up to 2000 contacts a call. list takes either an existing list id or a name to find-or-create; omitting it is fine.

curl -X POST https://workmylist.com/api/v1/contacts \
  -H "Authorization: Bearer wml_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: tenure-2026-09-11" \
  -d '{
        "list": "Tenure 7-12y",
        "contacts": [
          {
            "first_name": "Sam",
            "last_name": "Reed",
            "phone": "0412345678",
            "address": "12 Smith St, Bulimba QLD 4171",
            "years_in_home": 9,
            "last_contacted_at": "2024-03-02",
            "provenance": "crm_export"
          }
        ]
      }'

# -> {"ok":true,"list_id":"...","sent":1,"inserted":1,"skipped":0,"errors":[]}

inserted and skipped will not always add up the way you expect, and that is correct: we normalise phone numbers and dedupe against contacts already on file, so re-posting a list you have sent before is safe and mostly lands as skips.

Idempotency-Key is honoured here and nowhere else

Send the same key with the same body and the recorded response comes back instead of a second import. This matters because a caller whose connection dropped cannot tell a timeout from a failure, and two posts of the same new batch are two legitimate-looking imports. The campaigns endpoint does not need it: see below.

3. Start a run

POST /api/v1/campaigns, scope campaigns:initiate. Pass "activate": false to stage a draft instead.

curl -X POST https://workmylist.com/api/v1/campaigns \
  -H "Authorization: Bearer wml_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Tenure 7-12y","list_id":"...","activate":true}'

# -> {"ok":true,"created":true,"campaign_id":"...","list_id":"...",
#     "workflow":"...","status":"live","posture":{...}}

Retrying is safe. One live campaign is allowed per list, so a repeated POST returns the existing one with "created": false rather than opening a second and double-dialling the list.

The response carries posture on purpose. A script that gets {"ok": true}and then hears nothing ring has no way to tell “paused” from “trial lapsed” from “pacing twenty-five a day against a list of nine hundred”. All three are correct behaviour and all three look like silence, so the numbers come back with the campaign.

4. Read back what the gate decided

GET /api/v1/audit, scope audit:read. Read-only by construction: there is no writer on this route. A trail an API could edit would not be a trail.

curl -H "Authorization: Bearer wml_live_..." \
  "https://workmylist.com/api/v1/audit?type=DNCR_EXEMPTION_CLAIMED&limit=100"

# -> {"ok":true,"since":"...","until":null,"count":100,
#     "next_cursor":"...","rows":[...]}
  • type — comma-separated. An unknown value returns the valid list rather than silently matching nothing. Includes DNCR_EXEMPTION_CLAIMED, OPT_OUT_RECORDED, RECORDING_CONSENT_DECLINED, CALL_OUTCOME, APPRAISAL_BOOKED, CONTACT_DATA_DELETED.
  • since / until — ISO timestamps. Default window is the last 30 days, and the window you actually got is echoed back.
  • contact_id / conversation_id — narrow to one.
  • limit — default 50, max 200.
  • cursor — pass back next_cursor for the next page. It is keyset, so nothing written meanwhile is skipped. null means last page.

Rate limits

60 requests a minute per key by default, settable per key. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 carries Retry-After in seconds. Batch your contacts into one call rather than looping one contact per request.

What the API cannot do

This is the part worth reading before you design around it. A campaign going live is permission to consider a list, not permission to ring anybody on it. Every contact is still evaluated at dial time, below the API, where no caller can reach past it:

  • Warm only. A contact with no prior relationship does not get called.
  • Do not contact and opt-outs, honoured across every campaign.
  • Calling hours in the contact’s own timezone, not yours and not the server’s.
  • The Do Not Call Register, with the exemption basis recorded when one is claimed.
  • AI and recording disclosure on the call itself.
  • Billing state. A lapsed trial stops calling regardless of what the API returns.

The pass test

Put a number that is on the Do Not Call Register into a list, give it your highest score, and start the run. It must not be rung, and GET /api/v1/audit must tell you why it was not. Run the same test against whatever you built yourself. If yours rings it, your gate is advice rather than a rule, and the first time that matters will be the time somebody is looking.

Questions, or something here does not match what the API actually did? Tell us — a reference that has drifted from the routes is worse than none.