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.
Step 1. Get an API key
An administrator of your Matrix TimeCard account makes the key:
- Sign in to the Matrix TimeCard admin site.
- Open API Keys in the side menu. You need the API keys: edit permission.
- Click Create key.
- Enter a name that says which system uses the key, for example
ERP job sync. - Pick the scopes that the key needs. To write Jobs, pick
activity:readandactivity:write. Addgeofences:writeto draw boundaries. Addgeofences:publishto make boundaries live at once. Withoutgeofences:publish, a boundary that your key sends waits as a draft until an administrator publishes it. See Authentication for every scope. - 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. - Optional: limit the key to one company, and set an expiry date.
- 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 get | It means |
|---|---|
200 | The 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_invalid | The key is wrong. Copy it again, with no spaces. |
401 api_key_revoked or api_key_expired | Ask your administrator for a new key. |
403 insufficient_scope | The 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
- Store the key in a secrets manager. Never put it in source code.
- Map your own job numbers to the
codefield. Matrix TimeCard stores Job codes in upper case. - Keep the
activityIdthat Matrix TimeCard returns, or look a Job up by its code withGET /activities?q=<code>. - Always send
expectedUpdatedAton aPATCH, and handle409by reading the record again. - For a first load of many Jobs, use
POST /activities/import(up to 1,000 rows a request). Send"dryRun": truefirst. - Handle
429and503with a wait and a retry. See Errors and Terms and limits. - Remember that a
tc_test_key writes live data. Test against a test account that your administrator sets up.