Sredify API
Read a company's SR&ED projects, people, assessments, hours and claims. Add recorded time, notes and links.
Overview
- Base address:
https://app.sredify.ca/api/v1. JSON in, JSON out. Version 1; anything that breaks your code would go in a new version. - The whole API as an OpenAPI 3.1 file, for code generators and tools like Postman.
- Two ways in. An API key is for a company's own scripts: an Admin creates it in the app. An app is for software used by many companies (a timesheet tool, a browser extension): you register it on Developers, and each company's Admin approves it.
- Each key or app works in one company. Everything is limited to that company.
- Never available: source code, GitHub or Jira tokens, sign-in details, member emails and roles, the company's history log, other companies.
Quick start: API key
A company Admin opens Account, Apps and API, clicks New key, picks the permissions and copies the key (shown once). Then:
# 1. A company Admin creates a key: Account, Apps and API, New key.
export SRED_KEY="sred_key_..."
# 2. Read the company's projects.
curl -s https://app.sredify.ca/api/v1/projects -H "Authorization: Bearer $SRED_KEY"
# 3. Record time (needs the "Record time" permission on the key).
curl -s https://app.sredify.ca/api/v1/hours \
-H "Authorization: Bearer $SRED_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"projectId":"p_robot","email":"rita@acme.example","date":"2026-03-14","hours":7.5,"description":"Grip force tests"}'Connect an app
Apps use OAuth 2.0 with PKCE (the standard way Google, Microsoft and others let apps connect). Register your app on Developers. Pick the kind:
- Browser extension or web page (no secret). The code runs on the person's computer, so it can't keep a secret. PKCE protects each sign-in.
- Server app (with a secret). The code runs on your servers. It sends PKCE and its secret, so a stolen code or refresh token is useless without the secret.
- Make a random
code_verifier(43 to 128 characters) and itscode_challenge= base64url(SHA-256(verifier)). - Send the person to
https://app.sredify.ca/oauth/authorizewithresponse_type=code,client_id,redirect_uri(exactly as registered),scope(space separated),state,code_challenge,code_challenge_method=S256. - They sign in and a company Admin approves. Only Admins can approve; Editors and View only see that they can't. They come back to your
redirect_uriwith?code=&state=, or?error=access_deniedif they cancel. - Within 10 minutes, POST the code to
https://app.sredify.ca/api/oauth/tokenonce. You get an access token (60 minutes) and a refresh token (30 days). - Call the API with
Authorization: Bearer sred_at_.... When it expires, POST the refresh token to the same address. The refresh token changes every time: save the new one. Using an old one again signs your app out of that company (we assume it was stolen). - To disconnect, POST the token to
https://app.sredify.ca/api/oauth/revoke.
New apps show an “Unverified app” warning on the approval screen until Sredify checks who is behind them. Return addresses must be https://, http://localhost, or an extension address (chrome-extension://, https://<id>.chromiumapp.org/).
Browser extension sample
A complete Chrome extension background script: connect, keep tokens fresh, call the API, disconnect.
// background.js (Chrome extension, Manifest V3)
// manifest.json needs: "permissions": ["identity", "storage"]
// Register this extension's return address on /developers:
// chrome.identity.getRedirectURL() -> https://<extension-id>.chromiumapp.org/
const BASE = "https://app.sredify.ca";
const CLIENT_ID = "sred_app_..."; // from /developers
const REDIRECT = chrome.identity.getRedirectURL();
const b64url = (buf) =>
btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
async function tokenRequest(params) {
const r = await fetch(BASE + "/api/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(params),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error_description || j.error);
return j;
}
const save = (t) => chrome.storage.local.set({ tokens: { ...t, expiresAt: Date.now() + t.expires_in * 1000 } });
// 1. Connect: the person signs in and a company Admin approves.
async function connect(scope = "projects:read hours:write") {
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
const state = b64url(crypto.getRandomValues(new Uint8Array(16)));
const url = BASE + "/oauth/authorize?" + new URLSearchParams({
response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT, scope, state,
code_challenge: challenge, code_challenge_method: "S256",
});
const back = new URL(await chrome.identity.launchWebAuthFlow({ url, interactive: true }));
if (back.searchParams.get("state") !== state) throw new Error("State mismatch");
if (back.searchParams.get("error")) throw new Error(back.searchParams.get("error_description") || back.searchParams.get("error"));
await save(await tokenRequest({
grant_type: "authorization_code", code: back.searchParams.get("code"),
redirect_uri: REDIRECT, client_id: CLIENT_ID, code_verifier: verifier,
}));
}
// 2. A fresh access token. Refresh tokens change on EVERY use, and using an old
// one signs the app out (we treat it as stolen). So refresh one at a time.
let refreshing = null;
async function accessToken() {
const { tokens } = await chrome.storage.local.get("tokens");
if (!tokens) throw new Error("Not connected");
if (Date.now() < tokens.expiresAt - 60_000) return tokens.access_token;
refreshing ??= tokenRequest({ grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: CLIENT_ID })
.then(async (t) => { await save(t); return t.access_token; })
.finally(() => { refreshing = null; });
return refreshing;
}
// 3. Call the API.
async function api(method, path, body) {
const r = await fetch(BASE + "/api/v1" + path, {
method,
headers: {
Authorization: "Bearer " + (await accessToken()),
...(body ? { "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const j = r.status === 204 ? null : await r.json();
if (!r.ok) throw new Error(j?.error?.message || r.statusText);
return j;
}
// Example: record 7.5 hours for Rita today.
// await api("POST", "/hours", { projectId: "p_robot", email: "rita@acme.example",
// date: new Date().toLocaleDateString("en-CA"), hours: 7.5, description: "Grip force tests" });
// 4. Disconnect.
async function disconnect() {
const { tokens } = await chrome.storage.local.get("tokens");
if (tokens) await fetch(BASE + "/api/oauth/revoke", { method: "POST", body: new URLSearchParams({ token: tokens.refresh_token, client_id: CLIENT_ID }) });
await chrome.storage.local.remove("tokens");
}
Server app sample
# Server app: same flow, plus your client secret on every token request.
# 1. Send the person to /oauth/authorize (as above) with your own PKCE verifier + challenge.
# 2. They come back to your redirect address with ?code=...&state=... (check state).
# 3. Swap the code (within 10 minutes, once):
curl -s https://app.sredify.ca/api/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri="https://yourapp.example.com/sred/callback" \
-d code_verifier="$VERIFIER"
# -> {"access_token":"sred_at_...","token_type":"Bearer","expires_in":3600,
# "refresh_token":"sred_rt_...","scope":"projects:read hours:write"}
# 4. When the access token expires (1 hour), refresh. Store the NEW refresh token.
curl -s https://app.sredify.ca/api/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN"
# 5. Call the API.
curl -s https://app.sredify.ca/api/v1/projects -H "Authorization: Bearer $ACCESS_TOKEN"Permissions
Ask only for what you need. People see each one in plain words before they approve.
- company:read
- Company details. Business info, CRA numbers, addresses, SR&ED profile.
- people:read
- People. Names, titles, employment type and dates, R&D role, qualifications. No pay.
- pay:read
- Pay. Pay type and amount per person, salary amounts in claims. Sensitive: only an Admin can approve it, and it stops if that person is no longer an Admin.
- projects:read
- Projects. Projects, their repos, team and Jira projects.
- assessments:read
- Assessments. Saved assessments, eligible work, criteria, commit and Jira references.
- hours:read
- Hours. Hours per person per project: estimated, recorded, and which counts.
- claims:read
- Claims. Contractors, materials, equipment, funding. Salary amounts and totals need Pay too.
- evidence:read
- Evidence. Recorded time, notes and links, with where each came from.
- hours:write
- Record time. Add, change and remove its own time entries. Never changes estimates.
- evidence:write
- Add notes and links. Add, change and remove its own notes and links.
Rules
- Who you act as. A key acts as the Admin who created it, an app as the Admin who approved it, with their current role and never more than the permissions given. If that person becomes View only, writes stop. If they leave the company, the key or app stops.
- Labels. Everything you add shows in the app with your key's or app's name and the Admin behind it, for example “Acme Timer · approved by Ann Admin”.
- Recorded time replaces the estimate for that person on that day; it is never added on top. Estimates can't be changed through the API.
- Who time can be recorded for. Someone on the company's People list and on the project: ticked on its Team, or linked to a GitHub contributor with commits in its repos.
- Limits per day. More than 0 and at most 24 hours per entry; a person's recorded total for the day, across all projects, can't go over the company's daily maximum (
GET /org,sred.maxHoursPerDay). No future dates. - Your own items only. You can change or remove only what your key or app added. Removing keeps the item in history with your reason.
- Revoking. An Admin can revoke a key or app at any time, and can also remove everything an app added.
Requests and responses
- Dates are
yyyy-mm-dd; times are ISO 8601 UTC. Money is in Canadian dollars. - Lists that can be long return
{ data, nextCursor }. Pass?cursor=for the next page;nextCursorisnullon the last one.?limit=1 to 200, default 50. - Syncing evidence:
GET /evidence?since=with the time of your last sync returns only changed items (oldest first), including removals withincludeRemoved=true. - Caching: every GET returns an
ETag. Send it back asIf-None-Matchand get304 Not Modifiedwhen nothing changed. - Retries: send an
Idempotency-Keyheader on POST. Sending the same key again within 24 hours returns the first result instead of adding twice. - Request limits: 120 a minute per API key, 600 a minute per app (across all companies). Over that you get
429withRetry-After. - Browsers: CORS is open, so web pages and extensions can call the API directly. Sign-in cookies are never accepted, only tokens.
- Every call is logged for 90 days (which key or app, address, result, time). Admins see the usage of each key and app.
Errors
Errors are { "error": { "code", "message" } }. Branch on code; show message to people. The token endpoint follows the OAuth standard instead: { "error", "error_description" } with codes like invalid_grant and invalid_client.
- 400 bad_json
- The body isn't valid JSON.
- 400 bad_range
- from/to missing one, not yyyy-mm-dd, or from after to.
- 400 bad_cursor
- The cursor isn't one we gave you.
- 400 bad_since
- since isn't a date.
- 401 unauthorized
- No Authorization header, or an unknown API key.
- 401 key_revoked
- An Admin revoked the key.
- 401 key_expired
- The key reached its expiry date.
- 401 invalid_token
- Unknown or revoked app access token.
- 401 token_expired
- The app access token is over 1 hour old. Use the refresh token.
- 401 app_revoked
- The company revoked your app.
- 403 missing_scope
- The key or app doesn't have that permission.
- 403 role_too_low
- The Admin behind the key or app now has a role that doesn't allow it (for example View only can't write).
- 403 creator_removed
- The person who created the key left the company.
- 403 approver_removed
- The Admin who approved the app left the company.
- 403 app_suspended
- Sredify suspended the app.
- 403 company_suspended
- Sredify suspended the company.
- 403 account_suspended
- Sredify suspended the person who made the key or approved the app.
- 403 not_yours
- You tried to change an item someone else added.
- 404 not_found
- No such item in this company.
- 409 removed
- The item was already removed.
- 409 conflict
- Someone changed the same item at the same moment. Try again.
- 422 invalid
- The data breaks a rule (unknown person, not on the project, too many hours, future date, ...). The message says which.
- 429 rate_limited
- Too many requests. Wait for Retry-After seconds.
- 500 save_failed
- Something went wrong saving. Safe to retry with the same Idempotency-Key.
Endpoints
In the samples, $TOKEN is an API key or an app access token.
/api/v1/orgThe company
Company profile and SR&ED settings.
Permission: company:read
curl -s "https://app.sredify.ca/api/v1/org" \
-H "Authorization: Bearer $TOKEN"{
"id": "cmorg1",
"name": "Acme Robotics Inc.",
"business": {
"legalName": "Acme Robotics Inc.",
"operatingName": "Acme Robotics",
"businessNumber": "123456789",
"industry": "Robotics",
"naics": "333249"
},
"sred": {
"ccpc": true,
"fiscalYearEnd": "12-31",
"claimedBefore": false,
"maxHoursPerDay": 10
}
}/api/v1/peoplePeople
Everyone on the company's People list. Pay is included only with pay:read.
Permission: people:read
- Add pay:read to get each person's pay type and amount.
curl -s "https://app.sredify.ca/api/v1/people" \
-H "Authorization: Bearer $TOKEN"{
"data": [
{
"id": "per_rita",
"name": "Rita Robotics",
"email": "rita@acme.example",
"title": "Engineer",
"department": "R&D",
"employmentType": "full-time",
"startDate": "2024-01-08",
"endDate": null,
"province": "ON",
"rdRole": "technical",
"sredPercent": 80,
"specifiedEmployee": false,
"armsLength": true,
"degree": "BEng",
"field": "Mechatronics",
"yearsExperience": "6",
"githubAuthor": "Rita R",
"jiraAccountId": null
}
]
}/api/v1/projectsProjects
Every project in the company.
Permission: projects:read
curl -s "https://app.sredify.ca/api/v1/projects" \
-H "Authorization: Bearer $TOKEN"{
"data": [
{
"id": "p_robot",
"name": "Robot arm",
"description": "",
"repos": [
{
"key": "acme/robot",
"language": "TypeScript"
}
],
"teamIds": [
"per_rita"
],
"jiraProjectKeys": [
"ARM"
],
"createdAt": "2026-01-04T17:00:00.000Z",
"updatedAt": "2026-03-01T17:00:00.000Z"
}
]
}/api/v1/projects/{id}One project
A single project.
Permission: projects:read
curl -s "https://app.sredify.ca/api/v1/projects/p_robot" \
-H "Authorization: Bearer $TOKEN"/api/v1/assessmentsAssessments
Saved assessments, newest first. Changed files are left out here; get one assessment for those. Source code is never included.
Permission: assessments:read
Query
- projectId
- Only assessments of this project's repos.
- repo
- Only this repo (owner/repo).
- kind
- past or future.
- limit
- Items per page, 1 to 200 (default 50).
- cursor
- nextCursor from the previous page.
curl -s "https://app.sredify.ca/api/v1/assessments" \
-H "Authorization: Bearer $TOKEN"/api/v1/assessments/{id}One assessment
A single assessment, including the changed file names for each piece of eligible work.
Permission: assessments:read
curl -s "https://app.sredify.ca/api/v1/assessments/as_123" \
-H "Authorization: Bearer $TOKEN"/api/v1/hoursHours
Hours per person per project: estimated, recorded, and what counts. Recorded time replaces the estimate for that person on that day; it is never added on top.
Permission: hours:read
Query
- projectId
- Only this project.
- from
- Start day, yyyy-mm-dd. Send with to. Default: the project's claim dates (last fiscal year).
- to
- End day, yyyy-mm-dd.
curl -s "https://app.sredify.ca/api/v1/hours" \
-H "Authorization: Bearer $TOKEN"{
"from": "2025-01-01",
"to": "2025-12-31",
"rule": "Recorded time replaces the estimate for that person on that day; it is never added on top.",
"hours": [
{
"projectId": "p_robot",
"personId": "per_rita",
"name": "Rita Robotics",
"onTeam": true,
"estimated": 412.5,
"replacedByRecorded": 6,
"recorded": 7.5,
"recordedDays": 1,
"total": 420,
"recordedByDay": {
"2025-03-14": {
"estimateReplaced": 6
}
}
}
]
}/api/v1/hoursRecord time
Record time a person worked on a project. It counts in the claim right away, labelled with your key or app.
Permission: hours:write
- The person must be on the company's People list and on the project: ticked on its Team, or linked to a GitHub contributor with commits in its repos.
- Hours: more than 0 and at most 24. The person's recorded total that day, across all projects, can't go over the company's daily maximum (GET /org: sred.maxHoursPerDay, default 10).
- Not in the future. Recorded time replaces that day's estimate for the person.
Query and headers
- Idempotency-Key
- Any unique string (for example a UUID). Sending the same key again within 24 hours returns the first result instead of adding twice.
Body (JSON)
- projectId *string
- The project id (GET /projects).
- personIdstring
- The person's id. Or send email instead.
- emailstring
- The person's email on the People list.
- date *string
- The day the work happened. Not in the future.
- hours *number
- More than 0, at most 24. Rounded to 0.1.
- description *string
- What they worked on (at most 300 characters).
- notesstring
- Optional longer notes (at most 5000 characters).
curl -s -X POST "https://app.sredify.ca/api/v1/hours" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"projectId":"p_robot","email":"rita@acme.example","date":"2026-03-14","hours":7.5,"description":"Grip force tests on prototype 3"}'{
"id": "ev_mf2k1q_a8c3d1",
"projectId": "p_robot",
"type": "time",
"date": "2026-03-14",
"personId": "per_rita",
"hours": 7.5,
"title": "Grip force tests on prototype 3",
"body": null,
"url": null,
"source": {
"kind": "app",
"id": "cmuabc123",
"label": "Acme Timer",
"approvedBy": "Ann Admin"
},
"createdAt": "2026-03-14T23:10:00.000Z",
"updatedAt": "2026-03-14T23:10:00.000Z",
"removed": null
}/api/v1/hours/{id}Change a time entry
Change a time entry your key or app added. Send only the fields to change. The person can't change: remove the entry and add a new one.
Permission: hours:write
- Only items your own key or app added (403 not_yours otherwise).
Body (JSON)
- datestring
- The day the work happened.
- hoursnumber
- More than 0, at most 24.
- descriptionstring
- What they worked on.
- notesstring | null
- Send null or empty to clear.
curl -s -X PATCH "https://app.sredify.ca/api/v1/hours/ev_mf2k1q_a8c3d1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"hours":6}'/api/v1/hours/{id}Remove a time entry
Remove a time entry your key or app added. It stops counting and is kept in history with the reason.
Permission: hours:write
Query and headers
- reason *
- Why it's removed (shown in the app's history).
curl -s -X DELETE "https://app.sredify.ca/api/v1/hours/ev_mf2k1q_a8c3d1?reason=Entered%20twice" \
-H "Authorization: Bearer $TOKEN"/api/v1/claims/{projectId}A project's claim
The claim numbers for a project, calculated exactly as on the Claim screen.
Permission: claims:read
- Salary amounts, totals and the credit estimate need pay:read too. Without it you get hours and shares only, plus a note.
Query
- from
- Start day, yyyy-mm-dd. Send with to. Default: the project's claim dates (last fiscal year).
- to
- End day, yyyy-mm-dd.
curl -s "https://app.sredify.ca/api/v1/claims/p_robot" \
-H "Authorization: Bearer $TOKEN"/api/v1/evidenceRecorded time, notes and links
Everything added as evidence (in the app, by keys and by apps), oldest change first. Use since= to sync only what changed.
Permission: evidence:read
Query
- projectId
- Only this project.
- type
- time, note or link.
- since
- Only items changed at or after this ISO date or date-time.
- includeRemoved
- true to include removed items.
- limit
- Items per page, 1 to 200 (default 50).
- cursor
- nextCursor from the previous page.
curl -s "https://app.sredify.ca/api/v1/evidence" \
-H "Authorization: Bearer $TOKEN"/api/v1/evidenceAdd a note or link
Add a note or a link (a design doc, a test report, a ticket) to a project.
Permission: evidence:write
- Links must be full https:// (or http://) addresses.
Query and headers
- Idempotency-Key
- Any unique string (for example a UUID). Sending the same key again within 24 hours returns the first result instead of adding twice.
Body (JSON)
- projectId *string
- The project id (GET /projects).
- type *string
- One of: note, link.
- date *string
- The day it's about. Not in the future.
- title *string
- Short title (at most 300 characters).
- bodystring
- Optional text (at most 5000 characters).
- urlstring
- Links only: a full https:// address.
curl -s -X POST "https://app.sredify.ca/api/v1/evidence" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"projectId":"p_robot","type":"link","date":"2026-03-14","title":"Grip test report","url":"https://docs.acme.example/grip-tests"}'/api/v1/evidence/{id}One evidence item
A single item, removed or not.
Permission: evidence:read
curl -s "https://app.sredify.ca/api/v1/evidence/ev_mf2k1q_a8c3d1" \
-H "Authorization: Bearer $TOKEN"/api/v1/evidence/{id}Change a note or link
Change an item your key or app added. Time entries need hours:write instead.
Permission: evidence:write
Body (JSON)
- datestring
- A day, yyyy-mm-dd.
- titlestring
- Short title.
- bodystring | null
- Send null or empty to clear.
- urlstring
- Links only.
curl -s -X PATCH "https://app.sredify.ca/api/v1/evidence/ev_mf2k1q_a8c3d1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Grip test report v2"}'/api/v1/evidence/{id}Remove a note or link
Remove an item your key or app added. Kept in history with the reason. Time entries need hours:write instead.
Permission: evidence:write
Query and headers
- reason *
- Why it's removed (shown in the app's history).
curl -s -X DELETE "https://app.sredify.ca/api/v1/evidence/ev_mf2k1q_a8c3d1?reason=Entered%20twice" \
-H "Authorization: Bearer $TOKEN"