Authorizing with OAuth
This is the reference for Friday's OAuth 2.0 authorization server: the three /oauth endpoints, the permission model, how tokens and connections behave over time, and every error you need to handle. It assumes you already know what you are building and want the exact contract. If you are starting from scratch, work through Building an OAuth App first — it walks the whole flow end to end in sandbox with a test company you own. Come back here when you need the details that guide moves past.
Before You Start
You need a registered app from the Developer Portal: a client_id, a client secret for the environment you are calling, and at least one registered redirect URI.
Base URLs
The authorization endpoints sit at the server root, not under /partner/v1. This trips people up when copying examples — /partner/v1/oauth/token does not exist.
| Environment | Authorization server | Friday API |
|---|---|---|
| Sandbox | https://node.friday-staging.com/oauth | https://node.friday-staging.com/partner/v1 |
| Production | https://api.fridayapp.com/oauth | https://api.fridayapp.com/partner/v1 |
Examples below use sandbox. Swap the host for production; nothing else about the contract changes.
What you need to hold per connection
A "connection" is one customer company that authorized your app. You get one per company, and each has its own tokens. At minimum, persist:
| Value | Why |
|---|---|
refresh_token | Your only way to get new access tokens. Rotates on every use — see Refreshing. |
access_token + expiry | Reuse it until it expires rather than refreshing per call. |
scope | What you were actually granted, which can be narrower than what you asked for. |
company_id | Returned by GET /companies; also on every webhook payload, so it is how you match an event to a connection. |
OAuth or an API key? OAuth is for apps that many Friday companies connect to, each customer authorizing you themselves. For an integration against your own company, an API key is far less work — see Getting API Access.
The Flow
Your app Friday Your backend
│ │ │
│ 1. redirect user ──────►│ │
│ GET /oauth/authorize │ │
│ │ │
│ 2. consent screen │
│ owner/admin approves │
│ │ │
│◄──── 3. redirect back ───┤ │
│ ?code=...&state=... │ │
│ │ │
│ 4. hand code to your backend ─────────────────────────►│
│ │ │
│ │◄──── 5. POST /oauth/token ───┤
│ │ code + code_verifier │
│ │ + client_secret │
│ ├──── access + refresh ───────►│
│ │ │
│ │◄──── 6. GET /partner/v1/* ───┤
│ │ Authorization: Bearer │
Friday uses the authorization code flow with PKCE. PKCE is mandatory — there is no way to opt out, and plain challenges are rejected.
Steps 1–3 happen in the customer's browser. Steps 4–6 happen on your server, because they need your client secret.
Step 1 — GET /oauth/authorize
GET /oauth/authorizeA browser redirect, not an API call. It responds 302 to Friday's consent screen.
Before redirecting, generate a PKCE pair and keep the verifier — you need it in Step 2:
code_verifier = 43–128 random characters from [A-Za-z0-9-._~]
code_challenge = BASE64URL( SHA256( code_verifier ) ) // no padding
GET https://node.friday-staging.com/oauth/authorize
?client_id=feb2bc57-0157-4b6a-b155-fda37de26ef4
&redirect_uri=https://yourapp.com/oauth/callback
&response_type=code
&scope=timetracking:read%20users:read
&state=a3f1c0d9
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
| Parameter | Required | Notes |
|---|---|---|
client_id | Yes | Your app's client ID. Must be a valid UUID. |
redirect_uri | Yes | Matched exactly against your registered URIs — scheme, host, port, and path. |
response_type | Yes | Always code. |
scope | Yes | Space-separated. Every value must be a known scope or the request fails. |
state | Recommended | Returned unchanged on the callback. Max 512 characters. |
code_challenge | Yes | Base64url SHA-256 of your verifier, 43–128 characters. |
code_challenge_method | Yes | Always S256. |
Who can approve
The consent screen lists only companies where the signed-in user is an owner or admin, and they pick one. An employee-level user cannot authorize your app. If the browser is not signed in to Friday, they are asked to log in first and the OAuth parameters survive the round trip.
The callback
Approval redirects to your redirect_uri:
https://yourapp.com/oauth/callback?code=Ax7f2c1a9b...&state=a3f1c0d9
Verify state matches what you sent before you touch the code. The code is single-use and expires in 5 minutes.
Failures
Two classes, and the difference matters:
Unverified target — no redirect. If the client_id is unknown, the app is inactive, or the redirect_uri is not registered, Friday renders an HTML error page and returns 400. It will not redirect, not even to report the error, because an unverified redirect target must never receive one (RFC 6749 §4.1.2.1). During development, a blank-looking error page here almost always means a redirect URI typo.
Everything else — redirected back to you with error and error_description query parameters:
error | Cause |
|---|---|
unsupported_response_type | response_type was not code |
invalid_scope | Empty scope, or a value that is not a known scope |
invalid_request | Missing or malformed code_challenge, code_challenge_method other than S256, or state over 512 characters |
access_denied | The customer declined |
access_denied is a normal outcome, not a bug. Handle it as "customer changed their mind" and offer the connect button again.
On production, your app must be approved before anyone can authorize it. Sandbox skips review entirely, so a draft app works there indefinitely. On production the consent screen refuses an unapproved app with a
403. Submit for review from the app detail page in the portal before you launch.
Step 2 — POST /oauth/token
POST /oauth/tokenServer-side only — it requires your client secret. Body is application/x-www-form-urlencoded. Credentials go in the body as client_id / client_secret, or in an HTTP Basic Authorization header.
Exchanging the code
curl -X POST https://node.friday-staging.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=authorization_code \
-d code=Ax7f2c1a9b... \
-d redirect_uri=https://yourapp.com/oauth/callback \
-d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk \
-d client_id=feb2bc57-0157-4b6a-b155-fda37de26ef4 \
-d client_secret=your-client-secretredirect_uri must be the same one you sent to /oauth/authorize, and code_verifier must be the original verifier behind the challenge.
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3599,
"refresh_token": "113a659a0bfac07cd508cbb0f1d4e7a2...",
"scope": "timetracking:read users:read"
}Read scope from this response. It is what was actually granted, and it can be narrower than what you requested — the customer's plan may not cover every scope you asked for. Branch on it instead of assuming.
Refreshing an access token
Access tokens last one hour (expires_in is 3599).
curl -X POST https://node.friday-staging.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=refresh_token \
-d refresh_token=113a659a0bfac07cd508cbb0f1d4e7a2... \
-d client_id=feb2bc57-0157-4b6a-b155-fda37de26ef4 \
-d client_secret=your-client-secretThe response has the same shape, including a new refresh token.
Refresh tokens rotate on every use, and replaying one revokes the connection. The moment you refresh, the old refresh token is dead. If a used refresh token is presented again, Friday treats it as a leaked credential and revokes the entire connection — every token under it stops working and the customer has to reauthorize.
This is the single most common way to break a working integration. Two rules keep you safe: persist the new refresh token before you use the new access token, and refresh from one place, not from several workers that can race. If you run multiple processes, put refresh behind a lock or a single scheduled job.
Refresh tokens do not expire on a schedule. One lives until the connection is revoked, so a correctly-rotating integration never needs the customer to reauthorize.
Token endpoint errors
| Status | error | Meaning |
|---|---|---|
400 | invalid_grant | Code expired, already used, code_verifier did not match, redirect_uri did not match, or the connection is revoked |
400 | invalid_request | A required parameter is missing |
400 | unsupported_grant_type | grant_type is not authorization_code or refresh_token |
401 | invalid_client | Wrong client_id / client_secret, or the app is inactive |
{
"error": "invalid_grant",
"error_description": "Invalid grant: authorization code is invalid"
}Branch on error. Treat error_description as a human-readable hint and do not parse it.
Step 3 — Calling the Friday API
Pass the access token as a Bearer token. There is no company identifier in the URL — the token already names the company that authorized you.
curl https://node.friday-staging.com/partner/v1/employees \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."Right after connecting, call GET /companies to find out who you are connected to and store the company_id:
{
"data": {
"company_id": 42,
"name": "Acme Corp",
"email": "[email protected]",
"phone": "555-123-4567",
"timezone": "America/Los_Angeles",
"plan_name": "payroll"
}
}GET /companies needs no particular scope — every connected app can identify the company it is talking to.
Everything else in the API behaves identically to an API-key request: same paths, same response shapes, same pagination. See the Friday API Overview for the resource list.
Permissions
The eight scopes
| Scope | Grants | Needs plan |
|---|---|---|
users:read | View employees, departments, workplaces, and company details | Timetracking |
users:write | Create and manage employees, departments, and workplaces | Timetracking |
timetracking:read | View time records, customers, and projects | Timetracking |
timetracking:write | Create and manage time records, customers, and projects | Timetracking |
pto:read | View time-off policies, balances, and requests | Timetracking |
pto:write | Create and manage time-off requests | Timetracking |
payroll:read | View payrolls, payroll reports, and pay schedules | Payroll |
payroll:write | Create and manage payrolls and pay schedules | Payroll |
Request only what you use. Customers see every scope you ask for, and unexplained requests cost you approvals.
Which scope covers which endpoint
Read scopes cover GET; write scopes cover everything that changes data.
| Endpoints | Read | Write |
|---|---|---|
/companies | none | none |
/employees, /departments | users:read | users:write |
/customers, /projects, /time-records, /timetrack | timetracking:read | timetracking:write |
/pto | pto:read | pto:write |
/payrolls, /payroll-reports, /pay-schedule-groups | payroll:read | payroll:write |
/workplaces | timetracking:read or users:read | users:write |
/webhooks | none | none (see Webhooks) |
Calling outside your granted scopes returns 403 with a WWW-Authenticate: Bearer error="insufficient_scope" header and a message naming the permission in the same words the customer saw:
{
"error": "Your connection to this company does not include the 'payroll:read' permission. The company can grant it by re-approving your application with that permission included."
}Plans are a separate gate
Scopes say what the customer allowed; the company's plan says what exists to allow. A company on the timetracking plan cannot grant payroll:read at all — those scopes render disabled on the consent screen. A plan-level refusal is also a 403, but the message talks about upgrading rather than about permissions. Both are worth distinguishing in your error handling: one is fixed by asking the customer to re-approve, the other by asking them to upgrade.
Handling 401 and 403
401 and 403These two carry very different instructions for your code.
| Status | Body | What happened | What to do |
|---|---|---|---|
401 | Invalid or expired access token | Token expired, malformed, or signed for another environment | Refresh, then retry once |
401 | Access has been revoked | The customer disconnected you, or your app was deactivated | Stop calling. Mark the connection dead and prompt the customer to reconnect. Refreshing will not help. |
403 | ...does not include the '<scope>' permission | Missing scope | Re-request consent with the wider scope |
403 | Your plan does not include... | Company's plan is too low | Customer must upgrade |
403 | API access has been disabled for this company | Friday suspended API access for that company | Contact support |
429 | Rate limit exceeded. Retry after N seconds. | Too many requests | Back off (see below) |
Do not retry a 401 Access has been revoked on a loop. Treat it as final and surface it to the customer, who is the only one who can fix it.
Rate limits
100 requests per 60 seconds per connection, as a sliding window. The budget is per connected company, so one customer's heavy sync does not throttle another's. Responses carry standard RateLimit-* headers plus legacy X-RateLimit-*; 429 responses include Retry-After.
Connection Lifecycle
Adding scopes later
Send the customer through GET /oauth/authorize again with the wider scope. The existing connection is updated in place rather than duplicated — you do not end up with two connections to the same company, and the consent screen marks which permissions are new so the customer can see exactly what they are adding.
Exchange the resulting code as normal. Your old tokens for that company keep working, now with the wider scope.
When a customer narrows or revokes access
Customers manage connected apps from their Friday dashboard under Settings → Integrations → Developer / API Access, where they can see every app, the permissions it holds, who approved it, and when it was last used, and disconnect any of them.
Scope changes take effect immediately. Friday reads your permissions from the live connection on every request, not from the token you are holding, so a customer who removes a permission starts getting 403 on those endpoints right away — you do not keep the old scope until your token expires. A previously-working endpoint returning 403 on an unchanged token is this, not a bug in your code.
The same is true of disconnection: treat a 401 as effective immediately and do not count on a grace period.
Disconnecting from your side — POST /oauth/revoke
POST /oauth/revokeWhen a customer removes your app from your product, revoke the connection so nothing is left dangling (RFC 7009):
curl -X POST https://node.friday-staging.com/oauth/revoke \
-H "Content-Type: application/x-www-form-urlencoded" \
-d token=113a659a0bfac07cd508cbb0f1d4e7a2... \
-d client_id=feb2bc57-0157-4b6a-b155-fda37de26ef4 \
-d client_secret=your-client-secretPass a refresh token as token. This revokes the whole connection: every token under it, plus any webhook subscriptions registered against it.
{}200 comes back whether or not the token existed, so the endpoint cannot be used to probe which tokens are valid, and a token belonging to another app is silently ignored. You will get 400 only if token is missing entirely, and 401 if client authentication fails.
To reconnect afterwards, the customer goes through the consent flow again.
Webhooks for Connected Apps
Connected apps can register callback URLs at runtime with the customer's access token, which is what you want when the URLs do not exist until your users build something — one per automation scenario, for instance. These endpoints are OAuth-only; an API-key request gets a 403 pointing at the portal.
curl -X POST https://node.friday-staging.com/partner/v1/webhooks \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/hooks/scenario-8821",
"event_types": ["employee.created", "employee.updated"]
}'{
"data": {
"webhook_id": 41,
"url": "https://yourapp.com/hooks/scenario-8821",
"event_types": ["employee.created", "employee.updated"],
"created_at": "2026-09-20T14:22:05.000Z",
"secret": "9f2c...64 hex chars..."
}
}The secret is shown once — store it, you need it to verify signatures.
| Method | Path | Notes |
|---|---|---|
GET | /partner/v1/webhooks | Subscriptions for this connection |
POST | /partner/v1/webhooks | Create one; returns the signing secret once |
DELETE | /partner/v1/webhooks/:webhook_id | 204 on success, 404 if it is not yours |
Rules worth knowing up front:
- Subscribing needs the same permission as reading. Asking for
payroll.approvedwithoutpayroll:readreturns403naming the event types you cannot receive. A subscription can never become a way around what the customer approved. - 25 subscriptions per connection. Past that you get
409; delete one first. - The subscription is bound to the connection. It only ever receives that company's events, and it is deactivated automatically when the connection is revoked.
- Deliveries identify the company. Each payload carries
company_idandapi_company_uuid, which is how you route an event to the right connection when one app serves many companies.
Signature verification, retry behavior, and the full event catalog are the same as for portal-registered endpoints — see Subscribing to Webhooks.
Sandbox and Production
The two environments are fully separate. Nothing crosses.
| Sandbox | Production | |
|---|---|---|
| App review | Skipped — a draft app authorizes normally | Required before any customer can authorize |
| Redirect URIs | https or http://localhost | https only |
| Client secret | Per-environment | Per-environment, different value |
| Tokens | Valid only against sandbox | Valid only against production |
| Companies | Your own test company | Real customers |
Generating or rotating a secret in one environment does not touch the other. A sandbox access token presented to api.fridayapp.com fails, and vice versa.
Security Requirements
- The client secret never leaves your server. The code exchange is a backend call. PKCE is what protects the code in the browser; the secret must not be in your frontend, your mobile binary, or a public repository.
- Always send
state, always verify it on the callback. It is how you tie a callback to the request that started it. - Generate a fresh
code_verifierper authorization. Never reuse one across attempts. - Register exact redirect URIs. No wildcards, no prefix matching.
https://yourapp.com/callbackandhttps://yourapp.com/callback/are different URIs. - Store refresh tokens encrypted at rest. A refresh token is long-lived access to a customer's payroll data.
- Never log tokens, codes, or verifiers.
- Rotate your client secret if you suspect exposure. Rotating invalidates the old secret immediately; existing customer connections are unaffected, since they depend on tokens rather than on the secret.
Common Pitfalls
- Calling
/partner/v1/oauth/token— The authorization endpoints are at the server root, not under the API path. - Refreshing from several workers at once — Rotation means the loser of the race presents an already-used refresh token, which revokes the connection. Serialize refreshes.
- Using the new access token before persisting the new refresh token — If you crash in between, you have lost the connection. Store first.
- Assuming granted scope equals requested scope — Read
scopefrom the token response; the customer's plan may have narrowed it. - Retrying
401 Access has been revoked— The customer disconnected you. Refreshing cannot fix it; only they can. - Expecting removed scopes to keep working until the token expires — Permissions are read live on every request, so a downgrade applies immediately.
- A blank error page at
/oauth/authorize— Almost always an unregisteredredirect_urior an unknownclient_id. Friday deliberately will not redirect to tell you. - Trailing-slash redirect URI mismatches — Matching is exact.
- Testing only in sandbox — Sandbox skips app review. Your first production authorization fails until the app is approved.
- Sending
plainPKCE — OnlyS256is accepted.
Quick Reference
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/oauth/authorize | GET | None (browser redirect) | Start the flow; 302 to the consent screen |
/oauth/token | POST | Client ID + secret | Exchange a code, or refresh an access token |
/oauth/revoke | POST | Client ID + secret | Revoke a connection and all its tokens |
/partner/v1/* | — | Authorization: Bearer | The API itself |
| Thing | Value |
|---|---|
| Grant types | authorization_code, refresh_token |
| PKCE | Required, S256 only |
| Authorization code lifetime | 5 minutes, single use |
| Access token lifetime | 1 hour |
| Refresh token lifetime | Until the connection is revoked; rotates on every use |
| Rate limit | 100 requests / 60 seconds per connection |
| Scopes | 8, across users / timetracking / pto / payroll |
| Webhook subscriptions | 25 per connection |
What's Next
- Building an OAuth App — The guided sandbox build loop, including a test company you own.
- Friday API Overview — Base URLs, pagination, errors, rate limits, and every resource.
- Subscribing to Webhooks — Signature verification, retries, and the full event catalog.
- The Employee Object — Employee fields and employment types.
- Timetracking — Time records, clock-in/out, breaks, and reports.
Updated 3 days ago
