Skip to content

Developers

Getting started

API docs: Getting started

Follow these steps to make your first call and write your first Job. Each step shows the request in curl, JavaScript (fetch, Node 18 or later) and Python (requests), and then the response.

Back to the index

Step 1. Get an API key

An administrator of your Matrix TimeCard account makes the key:

  1. Sign in to the Matrix TimeCard admin site.
  2. Open API Keys in the side menu. You need the API keys: edit permission.
  3. Click Create key.
  4. Enter a name that says which system uses the key, for example ERP job sync.
  5. Pick the scopes that the key needs. To write Jobs, pick activity:read and activity:write. Add geofences:write to draw boundaries. Add geofences:publish to make boundaries live at once. Without geofences:publish, a boundary that your key sends waits as a draft until an administrator publishes it. See Authentication for every scope.
  6. Enter a source system name, for example acme-erp. A key that writes must have one. Every record that the key creates carries this name as its owner.
  7. Optional: limit the key to one company, and set an expiry date.
  8. Click Create. Copy the key now. Matrix TimeCard shows the key one time only and stores only a hash of it. Nobody can show it to you again. If you lose it, revoke it and create a new one.

A key looks like this: tc_live_ followed by 43 letters and digits.

Step 2. Find your base URL

Each Matrix TimeCard account has its own API address. Ask your administrator or support@matrixtimecard.com for it. It has this form:

https://<your-instance-host>/api/v1

The samples on this page read the base URL, the key and the company code from environment variables:

export TIMECARD_BASE_URL="https://<your-instance-host>/api/v1"
export TIMECARD_API_KEY="tc_live_..."
export TIMECARD_COMPANY="ACME"

Step 3. Make your first call

GET /reporting-types is the simplest call that needs a key. It reads no account data. Use it to check your base URL and your key. It needs the activity:read scope.

curl

curl "$TIMECARD_BASE_URL/reporting-types" \
  -H "X-API-Key: $TIMECARD_API_KEY"

JavaScript

const BASE_URL = process.env.TIMECARD_BASE_URL;
const headers = {
  'X-API-Key': process.env.TIMECARD_API_KEY,
  'Content-Type': 'application/json',
};

const res = await fetch(`${BASE_URL}/reporting-types`, { headers });
console.log(res.status, await res.json());

Python

import os
import requests

BASE_URL = os.environ["TIMECARD_BASE_URL"]
session = requests.Session()
session.headers.update({"X-API-Key": os.environ["TIMECARD_API_KEY"]})

res = session.get(f"{BASE_URL}/reporting-types")
print(res.status_code, res.json())

Response 200 OK

{
  "status": 200,
  "message": "Success: reporting types retrieved.",
  "records": [
    "Prevailing Wages",
    "Certified Payroll",
    "CCIP",
    "OCIP",
    "ROCIP",
    "ACIP",
    "PSI",
    "HUD"
  ]
}
You getIt means
200The key works. Go to step 4.
401 with "message": "Unauthorized: no bearer token was sent."No key was sent, or the value does not start with tc_live_ or tc_test_. Check the header.
401 api_key_invalidThe key is wrong. Copy it again, with no spaces.
401 api_key_revoked or api_key_expiredAsk your administrator for a new key.
403 insufficient_scopeThe key does not have activity:read.

Step 4. Write to a Job

This is the full loop for a Job sync. Your system creates a Job one time. Then it sends a PATCH each time the Job changes in your system. These steps need the activity:read and activity:write scopes.

4.1 Create a Job

POST /activities creates the Job, its Phase Codes and a job-site boundary of 201 m around the point that you send. Either all of it is saved or nothing is. Without the geofences:publish scope, the boundary is a draft (isPublished: false, hasDraftPending: true) until an administrator publishes it.

curl

curl -X POST "$TIMECARD_BASE_URL/activities" \
  -H "X-API-Key: $TIMECARD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "Job",
    "companyCode": "ACME",
    "code": "J-2026-104",
    "name": "Riverside Bridge Widening",
    "address": "1200 River Rd, Austin, TX 78701",
    "divisionCode": "PAVING",
    "timeZone": "America/Chicago",
    "phaseCodes": [
      { "code": "01-100", "description": "Mobilization" },
      { "code": "02-200", "description": "Earthwork" }
    ],
    "location": { "latitude": 30.2672, "longitude": -97.7431, "radiusMeters": 201 }
  }'

JavaScript

const createRes = await fetch(`${BASE_URL}/activities`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    kind: 'Job',
    companyCode: process.env.TIMECARD_COMPANY,
    code: 'J-2026-104',
    name: 'Riverside Bridge Widening',
    address: '1200 River Rd, Austin, TX 78701',
    divisionCode: 'PAVING',
    timeZone: 'America/Chicago',
    phaseCodes: [
      { code: '01-100', description: 'Mobilization' },
      { code: '02-200', description: 'Earthwork' },
    ],
    location: { latitude: 30.2672, longitude: -97.7431, radiusMeters: 201 },
  }),
});
const job = (await createRes.json()).record;
let activityId = job.activityId;
let updatedAt = job.updatedAt;

Python

res = session.post(f"{BASE_URL}/activities", json={
    "kind": "Job",
    "companyCode": os.environ["TIMECARD_COMPANY"],
    "code": "J-2026-104",
    "name": "Riverside Bridge Widening",
    "address": "1200 River Rd, Austin, TX 78701",
    "divisionCode": "PAVING",
    "timeZone": "America/Chicago",
    "phaseCodes": [
        {"code": "01-100", "description": "Mobilization"},
        {"code": "02-200", "description": "Earthwork"},
    ],
    "location": {"latitude": 30.2672, "longitude": -97.7431, "radiusMeters": 201},
})
res.raise_for_status()
job = res.json()["record"]
activity_id = job["activityId"]
updated_at = job["updatedAt"]

Response 201 Created

{
  "status": 201,
  "message": "Success: activity created.",
  "record": {
    "activityId": 48213,
    "kind": "Job",
    "companyCode": "ACME",
    "sourceSystem": "acme-erp",
    "code": "J-2026-104",
    "name": "Riverside Bridge Widening",
    "address": "1200 River Rd, Austin, TX 78701",
    "latitude": 30.2672,
    "longitude": -97.7431,
    "divisionCode": "PAVING",
    "timeZone": "America/Chicago",
    "isActive": true,
    "completeStatus": null,
    "jobCode": null,
    "isJobKnown": false,
    "equipmentCode": null,
    "isLocallyOverridden": false,
    "overridden": [],
    "sourceValues": {
      "name": "Riverside Bridge Widening",
      "address": "1200 River Rd, Austin, TX 78701",
      "latitude": 30.2672,
      "longitude": -97.7431
    },
    "phaseCodes": [
      { "code": "01-100", "description": "Mobilization" },
      { "code": "02-200", "description": "Earthwork" }
    ],
    "reportingType": null,
    "reportingFrequency": null,
    "programClosesOn": null,
    "audience": [],
    "geofences": [
      {
        "geofenceId": 9120,
        "isActive": true,
        "isPublished": false,
        "hasDraftPending": true,
        "sourceSystem": "acme-erp",
        "isLocallyManaged": false
      }
    ],
    "updatedAt": "2026-11-03T15:04:05.123Z"
  }
}

Keep activityId and updatedAt. The next steps use them. If the code is already used in the company, you get 409 conflict, and detail says which kind of record and which owner holds it.

A 500 on a create. A create can be saved even when it returns 500. Before you send it again, look the Job up by its code (step 4.5). See Errors.

4.2 Read the Job

GET /activities/{activityId} returns one activity.

curl

curl "$TIMECARD_BASE_URL/activities/48213" \
  -H "X-API-Key: $TIMECARD_API_KEY"

JavaScript

const readRes = await fetch(`${BASE_URL}/activities/${activityId}`, {
  headers,
});
const current = (await readRes.json()).record;
updatedAt = current.updatedAt;

Python

res = session.get(f"{BASE_URL}/activities/{activity_id}")
res.raise_for_status()
updated_at = res.json()["record"]["updatedAt"]

Response 200 OK

{
  "status": 200,
  "message": "Success: activity retrieved.",
  "record": {
    "activityId": 48213,
    "kind": "Job",
    "code": "J-2026-104",
    "updatedAt": "2026-11-03T15:04:05.123Z"
  }
}

The record has every field shown in step 4.1. It is shortened here.

4.3 Update the Job

PATCH /activities/{activityId} changes only the fields that you send. Send expectedUpdatedAt with the updatedAt that you last read. If somebody changed the Job after that, you get 409 conflict and nothing is saved.

curl

curl -X PATCH "$TIMECARD_BASE_URL/activities/48213" \
  -H "X-API-Key: $TIMECARD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expectedUpdatedAt": "2026-11-03T15:04:05.123Z",
    "name": "Riverside Bridge Widening - Phase 2",
    "address": "1250 River Rd, Austin, TX 78701"
  }'

JavaScript

const patchRes = await fetch(`${BASE_URL}/activities/${activityId}`, {
  method: 'PATCH',
  headers,
  body: JSON.stringify({
    expectedUpdatedAt: updatedAt,
    name: 'Riverside Bridge Widening - Phase 2',
    address: '1250 River Rd, Austin, TX 78701',
  }),
});
if (patchRes.status === 409) {
  // Read the Job again, apply your change, and send it again.
}
updatedAt = (await patchRes.json()).record.updatedAt;

Python

res = session.patch(f"{BASE_URL}/activities/{activity_id}", json={
    "expectedUpdatedAt": updated_at,
    "name": "Riverside Bridge Widening - Phase 2",
    "address": "1250 River Rd, Austin, TX 78701",
})
if res.status_code == 409:
    pass  # Read the Job again, apply your change, and send it again.
res.raise_for_status()
updated_at = res.json()["record"]["updatedAt"]

Response 200 OK

{
  "status": 200,
  "message": "Success: activity updated.",
  "record": {
    "activityId": 48213,
    "name": "Riverside Bridge Widening - Phase 2",
    "address": "1250 River Rd, Austin, TX 78701",
    "updatedAt": "2026-11-03T15:10:42.517Z"
  }
}

The record is shortened here. If the Job already has the values that you send, nothing changes and the message is Success: nothing changed. It is safe to retry.

Response 409 Conflict (the Job changed after you read it)

{
  "type": "/problems/conflict",
  "title": "This conflicts with an existing record.",
  "status": 409,
  "code": "conflict",
  "detail": "this job changed since you loaded it, so your changes to name and address were not saved. Reload to see the current values."
}

4.4 Add a Phase Code

POST /activities/{activityId}/phase-codes adds one Phase Code. You do not send the whole list again.

curl

curl -X POST "$TIMECARD_BASE_URL/activities/48213/phase-codes" \
  -H "X-API-Key: $TIMECARD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "03-300", "description": "Concrete" }'

JavaScript

const pcRes = await fetch(`${BASE_URL}/activities/${activityId}/phase-codes`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ code: '03-300', description: 'Concrete' }),
});
const phaseCode = (await pcRes.json()).record;

Python

res = session.post(f"{BASE_URL}/activities/{activity_id}/phase-codes",
                   json={"code": "03-300", "description": "Concrete"})
res.raise_for_status()
phase_code = res.json()["record"]

Response 201 Created

{
  "status": 201,
  "message": "Success: phase code added.",
  "record": {
    "phaseCodeId": 48230,
    "activityId": 48213,
    "code": "03-300",
    "description": "Concrete",
    "isActive": true,
    "sourceSystem": "acme-erp",
    "isLocallyOverridden": false,
    "overridden": []
  }
}

4.5 Find a Job by its code

Use this when your system knows the job number but not the activityId. The q parameter matches part of the code, the name or the address, so other Jobs can match too. Read every page (up to 100 records a page) and pick the record whose code is equal to your code. See Paging.

curl

curl "$TIMECARD_BASE_URL/activities?kind=Job&q=J-2026-104&companyCode=ACME&limit=100&offset=0" \
  -H "X-API-Key: $TIMECARD_API_KEY"

If record.total is more than 100, send the request again with offset=100, then offset=200, until you find the code or offset reaches total.

JavaScript

let found;
for (let offset = 0; ; offset += 100) {
  const params = new URLSearchParams({
    kind: 'Job',
    q: 'J-2026-104',
    companyCode: 'ACME',
    limit: '100',
    offset: String(offset),
  });
  const findRes = await fetch(`${BASE_URL}/activities?${params}`, { headers });
  const { total, records } = (await findRes.json()).record;
  found = records.find(r => r.code === 'J-2026-104');
  if (found || offset + 100 >= total) break;
}

Python

found = None
offset = 0
while True:
    res = session.get(f"{BASE_URL}/activities",
                      params={"kind": "Job", "q": "J-2026-104", "companyCode": "ACME",
                              "limit": 100, "offset": offset})
    res.raise_for_status()
    page = res.json()["record"]
    found = next((r for r in page["records"] if r["code"] == "J-2026-104"), None)
    offset += 100
    if found or offset >= page["total"]:
        break

Response 200 OK

{
  "status": 200,
  "message": "Success: activities retrieved.",
  "record": {
    "total": 1,
    "records": [
      {
        "activityId": 48213,
        "kind": "Job",
        "code": "J-2026-104",
        "name": "Riverside Bridge Widening - Phase 2",
        "updatedAt": "2026-11-03T15:12:08.004Z"
      }
    ]
  }
}

Each record has every field shown in step 4.1. It is shortened here. Matrix TimeCard stores Job codes in upper case, so compare in upper case.

4.6 Close a Job

The API never deletes a Job. To close it, send "isActive": false. Crews can no longer clock in to it. Past punches keep it. Send "isActive": true to open it again.

Adding the Phase Code in step 4.4 changed the Job's updatedAt. Send the updatedAt of the record you found in step 4.5, not the one from step 4.3, or you get 409.

curl

curl -X PATCH "$TIMECARD_BASE_URL/activities/48213" \
  -H "X-API-Key: $TIMECARD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "expectedUpdatedAt": "2026-11-03T15:12:08.004Z", "isActive": false }'

JavaScript

const closeRes = await fetch(`${BASE_URL}/activities/${activityId}`, {
  method: 'PATCH',
  headers,
  body: JSON.stringify({ expectedUpdatedAt: found.updatedAt, isActive: false }),
});
console.log((await closeRes.json()).record.isActive); // false

Python

res = session.patch(f"{BASE_URL}/activities/{activity_id}",
                    json={"expectedUpdatedAt": found["updatedAt"], "isActive": False})
res.raise_for_status()
print(res.json()["record"]["isActive"])  # False

Response 200 OK

{
  "status": 200,
  "message": "Success: activity updated.",
  "record": {
    "activityId": 48213,
    "isActive": false,
    "updatedAt": "2026-11-03T15:20:11.904Z"
  }
}

The record is shortened here.

Step 5. Go live

  1. Store the key in a secrets manager. Never put it in source code.
  2. Map your own job numbers to the code field. Matrix TimeCard stores Job codes in upper case.
  3. Keep the activityId that Matrix TimeCard returns, or look a Job up by its code with GET /activities?q=<code>.
  4. Always send expectedUpdatedAt on a PATCH, and handle 409 by reading the record again.
  5. For a first load of many Jobs, use POST /activities/import (up to 1,000 rows a request). Send "dryRun": true first.
  6. Handle 429 and 503 with a wait and a retry. See Errors and Terms and limits.
  7. Remember that a tc_test_ key writes live data. Test against a test account that your administrator sets up.