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.

EnvironmentAuthorization serverFriday API
Sandboxhttps://node.friday-staging.com/oauthhttps://node.friday-staging.com/partner/v1
Productionhttps://api.fridayapp.com/oauthhttps://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:

ValueWhy
refresh_tokenYour only way to get new access tokens. Rotates on every use — see Refreshing.
access_token + expiryReuse it until it expires rather than refreshing per call.
scopeWhat you were actually granted, which can be narrower than what you asked for.
company_idReturned 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

A 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
ParameterRequiredNotes
client_idYesYour app's client ID. Must be a valid UUID.
redirect_uriYesMatched exactly against your registered URIs — scheme, host, port, and path.
response_typeYesAlways code.
scopeYesSpace-separated. Every value must be a known scope or the request fails.
stateRecommendedReturned unchanged on the callback. Max 512 characters.
code_challengeYesBase64url SHA-256 of your verifier, 43–128 characters.
code_challenge_methodYesAlways 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:

errorCause
unsupported_response_typeresponse_type was not code
invalid_scopeEmpty scope, or a value that is not a known scope
invalid_requestMissing or malformed code_challenge, code_challenge_method other than S256, or state over 512 characters
access_deniedThe 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

Server-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-secret

redirect_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-secret

The 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

StatuserrorMeaning
400invalid_grantCode expired, already used, code_verifier did not match, redirect_uri did not match, or the connection is revoked
400invalid_requestA required parameter is missing
400unsupported_grant_typegrant_type is not authorization_code or refresh_token
401invalid_clientWrong 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

ScopeGrantsNeeds plan
users:readView employees, departments, workplaces, and company detailsTimetracking
users:writeCreate and manage employees, departments, and workplacesTimetracking
timetracking:readView time records, customers, and projectsTimetracking
timetracking:writeCreate and manage time records, customers, and projectsTimetracking
pto:readView time-off policies, balances, and requestsTimetracking
pto:writeCreate and manage time-off requestsTimetracking
payroll:readView payrolls, payroll reports, and pay schedulesPayroll
payroll:writeCreate and manage payrolls and pay schedulesPayroll

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.

EndpointsReadWrite
/companiesnonenone
/employees, /departmentsusers:readusers:write
/customers, /projects, /time-records, /timetracktimetracking:readtimetracking:write
/ptopto:readpto:write
/payrolls, /payroll-reports, /pay-schedule-groupspayroll:readpayroll:write
/workplacestimetracking:read or users:readusers:write
/webhooksnonenone (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

These two carry very different instructions for your code.

StatusBodyWhat happenedWhat to do
401Invalid or expired access tokenToken expired, malformed, or signed for another environmentRefresh, then retry once
401Access has been revokedThe customer disconnected you, or your app was deactivatedStop calling. Mark the connection dead and prompt the customer to reconnect. Refreshing will not help.
403...does not include the '<scope>' permissionMissing scopeRe-request consent with the wider scope
403Your plan does not include...Company's plan is too lowCustomer must upgrade
403API access has been disabled for this companyFriday suspended API access for that companyContact support
429Rate limit exceeded. Retry after N seconds.Too many requestsBack 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

When 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-secret

Pass 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.

MethodPathNotes
GET/partner/v1/webhooksSubscriptions for this connection
POST/partner/v1/webhooksCreate one; returns the signing secret once
DELETE/partner/v1/webhooks/:webhook_id204 on success, 404 if it is not yours

Rules worth knowing up front:

  • Subscribing needs the same permission as reading. Asking for payroll.approved without payroll:read returns 403 naming 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_id and api_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.

SandboxProduction
App reviewSkipped — a draft app authorizes normallyRequired before any customer can authorize
Redirect URIshttps or http://localhosthttps only
Client secretPer-environmentPer-environment, different value
TokensValid only against sandboxValid only against production
CompaniesYour own test companyReal 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_verifier per authorization. Never reuse one across attempts.
  • Register exact redirect URIs. No wildcards, no prefix matching. https://yourapp.com/callback and https://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 scope from 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 unregistered redirect_uri or an unknown client_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 plain PKCE — Only S256 is accepted.

Quick Reference

EndpointMethodAuthPurpose
/oauth/authorizeGETNone (browser redirect)Start the flow; 302 to the consent screen
/oauth/tokenPOSTClient ID + secretExchange a code, or refresh an access token
/oauth/revokePOSTClient ID + secretRevoke a connection and all its tokens
/partner/v1/*—Authorization: BearerThe API itself
ThingValue
Grant typesauthorization_code, refresh_token
PKCERequired, S256 only
Authorization code lifetime5 minutes, single use
Access token lifetime1 hour
Refresh token lifetimeUntil the connection is revoked; rotates on every use
Rate limit100 requests / 60 seconds per connection
Scopes8, across users / timetracking / pto / payroll
Webhook subscriptions25 per connection

What's Next


Did this page help you?