OAuth for applications

Updated 11 Oct, 2026 08:39 PM in API

If you're building an application that works with Tender for other people (an integration you'll offer to many Tender sites, a desktop tool, a service your colleagues connect to), use OAuth. Each supporter approves your application on their own Tender site, and your application gets tokens that act as that person, without anyone copying API tokens around.

For a script of your own, a personal API token is simpler.

Tender follows OAuth 2.1: the authorization code flow with PKCE, dynamic client registration, rotating refresh tokens and token revocation. Any standard OAuth library that supports PKCE will work.

How it fits together

  • Each Tender site is its own authorization server, at https://yoursite.tenderapp.com. (Sites with their own domain use their tenderapp.com address for this.)
  • The API at https://api.tenderapp.com/yoursite is the protected resource that the tokens unlock.
  • Only the site's support staff can approve an application, and its tokens act with that person's access: they see and do what the person could in Tender.

Discovery

Your application doesn't need any addresses built in except the API's. Call it without a token, and the 401 answer points the way:

curl -i -H "Accept: application/vnd.tender-v1+json" https://api.tenderapp.com/yoursite/discussions
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="Tender", resource_metadata="https://api.tenderapp.com/.well-known/oauth-protected-resource/yoursite", scope="read write"

The protected resource metadata (RFC 9728) names the authorization server:

curl https://api.tenderapp.com/.well-known/oauth-protected-resource/yoursite
{
  "resource": "https://api.tenderapp.com/yoursite",
  "resource_name": "Juniper Tender API",
  "authorization_servers": ["https://yoursite.tenderapp.com"],
  "scopes_supported": ["read", "write"],
  "bearer_methods_supported": ["header"]
}

And the authorization server's metadata (RFC 8414) lists its endpoints:

curl https://yoursite.tenderapp.com/.well-known/oauth-authorization-server
{
  "issuer": "https://yoursite.tenderapp.com",
  "authorization_endpoint": "https://yoursite.tenderapp.com/oauth/authorize",
  "token_endpoint": "https://yoursite.tenderapp.com/oauth/token",
  "registration_endpoint": "https://yoursite.tenderapp.com/oauth/register",
  "revocation_endpoint": "https://yoursite.tenderapp.com/oauth/revoke",
  "scopes_supported": ["read", "write"],
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"],
  "revocation_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"],
  "code_challenge_methods_supported": ["S256"],
  "authorization_response_iss_parameter_supported": true
}

Register your application

Applications register themselves with each site, using dynamic client registration (RFC 7591). No approval is needed to register: registering grants nothing until a supporter allows it.

curl -X POST https://yoursite.tenderapp.com/oauth/register \
     -H "Content-Type: application/json" \
     -d '{
           "client_name": "Billing Sync",
           "redirect_uris": ["http://127.0.0.1:8765/callback"],
           "token_endpoint_auth_method": "none",
           "grant_types": ["authorization_code", "refresh_token"]
         }'
HTTP/1.1 201 Created
Cache-Control: no-store

{
  "client_id": "tender_client_99eed931a1fe53c823ad644689cc3a1b",
  "client_id_issued_at": 1791530125,
  "client_name": "Billing Sync",
  "redirect_uris": ["http://127.0.0.1:8765/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "read write"
}

The registration is for that one site: register again for each site your application connects to, and keep the client_id for next time.

What you can send:

Field
redirect_uris Required. A list of up to 10 addresses Tender may send people back to (see Redirect URIs).
client_name The name supporters see when they approve your application (up to 100 characters). Without one, it's "Unnamed application".
token_endpoint_auth_method How your application proves itself at the token endpoint (see Client authentication). client_secret_basic if you leave it out.
grant_types authorization_code and refresh_token are the only ones.
response_types Only code.

Registration errors are 400 with a JSON body, such as:

{"error":"invalid_redirect_uri","error_description":"http://billing.example.com/cb must use https unless it goes to localhost"}

Client authentication

  • none: a public client, such as a desktop, mobile or browser app, which can't keep a secret. It sends only its client_id, and PKCE protects the exchange.
  • client_secret_basic or client_secret_post: a confidential client, such as a web service. The registration response includes a client_secret (starting tender_cs_, shown only this once, and it doesn't expire). Send it with HTTP Basic authentication (client_id:client_secret) or as client_id and client_secret fields in the request body.

Either way, PKCE is always required.

Redirect URIs

  • https:// addresses are accepted on any host.
  • http:// only to this machine: localhost, 127.0.0.1 or [::1]. For these, any port matches the registered address, since desktop apps pick a free port when they start.
  • An app's private scheme, such as com.example.billing:/callback.
  • No fragments (#…), and no javascript:, data:, file: and similar schemes.

Every other address must match a registered one exactly.

Get a supporter's authorization

This is the authorization code flow with PKCE (RFC 7636), S256 only.

1. Make a PKCE pair

Make a random code_verifier (43 to 128 letters, digits and -._~) and its code_challenge, the base64url SHA-256 of it without padding. Keep the verifier to yourself until step 4.

code_verifier=i8ca5CorTbRrbuTQ7Uo2hbTIZA4AbxuWhCS6HAAm9M4
code_challenge=eE0ja9Mi-6ciNHuMni9yUFjZJBndli6BS_uSRrha8L0

2. Send the supporter to the authorization page

Open this address in the person's browser (shown here on several lines):

https://yoursite.tenderapp.com/oauth/authorize
  ?response_type=code
  &client_id=tender_client_99eed931a1fe53c823ad644689cc3a1b
  &redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback
  &scope=read+write
  &state=af0ifjsldkj
  &code_challenge=eE0ja9Mi-6ciNHuMni9yUFjZJBndli6BS_uSRrha8L0
  &code_challenge_method=S256
  &resource=https%3A%2F%2Fapi.tenderapp.com%2Fyoursite
Parameter
response_type code.
client_id From the registration.
redirect_uri One of the registered ones. You can leave it out if you registered only one.
code_challenge, code_challenge_method From step 1; the method must be S256.
scope read, or read write (see Scopes). Both if you leave it out.
state Any value you like, sent back to you unchanged. Use it to match the answer to the request.
resource Which API the tokens are for (see Resources). Optional.

If the person isn't logged in to Tender, they log in first and come back to this page. Then they see what your application is asking for: its name, whether it wants to read only or read and write, and where they'll go back to. Tender tells them your application registered itself, and that they can revoke its access from their profile.

Only support staff can allow applications; anyone else is told so and isn't sent back to you. A request that's wrong (an unknown client_id, a redirect address that isn't registered, no PKCE) is explained on the page and never sent back to you either.

3. Get the code

When the person clicks Allow, Tender sends their browser back to your redirect address with a code, your state, and iss, the authorization server it came from (RFC 9207):

HTTP/1.1 302 Found
Location: http://127.0.0.1:8765/callback?code=tender_code_ruCdan-kqmO9kLIuryVXtHgep-Tnhusj-phB3gVskOo&iss=https%3A%2F%2Fyoursite.tenderapp.com&state=af0ifjsldkj

Check that state is yours and iss is the site you sent them to. The code works once, for 5 minutes.

If they click Deny, you get error=access_denied instead:

http://127.0.0.1:8765/callback?error=access_denied&error_description=The+request+was+denied&iss=https%3A%2F%2Fyoursite.tenderapp.com&state=af0ifjsldkj

4. Exchange the code for tokens

curl https://yoursite.tenderapp.com/oauth/token \
     -d grant_type=authorization_code \
     -d code=tender_code_ruCdan-kqmO9kLIuryVXtHgep-Tnhusj-phB3gVskOo \
     --data-urlencode redirect_uri=http://127.0.0.1:8765/callback \
     -d client_id=tender_client_99eed931a1fe53c823ad644689cc3a1b \
     -d code_verifier=i8ca5CorTbRrbuTQ7Uo2hbTIZA4AbxuWhCS6HAAm9M4 \
     --data-urlencode resource=https://api.tenderapp.com/yoursite
HTTP/1.1 200 OK
Cache-Control: no-store

{
  "access_token": "tender_oat_Cm_krrV_gHRetLI4KFf3IrCRFNbx30flF1V3RSX5u9o",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tender_ort_cOIo4UxpW2YQKwDorRuapHWD1RhL9FmjSIJ5B5LSvus",
  "scope": "read write"
}
  • If the authorization request had a redirect_uri, repeat it here exactly.
  • resource is optional here; if you send it, it must be the one you were authorized for.
  • A confidential client also authenticates, for example with -u "$CLIENT_ID:$CLIENT_SECRET".
  • Send everything in the body (form-encoded), never in the URL: a code, secret, verifier or token in the query string is refused.

5. Call the API

curl -H "Accept: application/vnd.tender-v1+json" \
     -H "Authorization: Bearer tender_oat_Cm_krrV_gHRetLI4KFf3IrCRFNbx30flF1V3RSX5u9o" \
     https://api.tenderapp.com/yoursite/profile

Access tokens go in the Authorization header only. The answers, errors and 401s are the same as with personal tokens.

Scopes

Scope Lets the token
read Make GET requests: see discussions, users, knowledge base articles and the rest.
write Also create, change and delete things. write includes read.

Ask for read if that's all you need: supporters see the difference on the authorization page. Scopes Tender doesn't know are left out.

A token without the scope a request needs gets 403 with error="insufficient_scope" and the scope it needs, so your application can ask the person again.

Resources

Tender has two protected resources, and a token is good for only one of them (RFC 8707 resource indicators):

resource The token works for
https://api.tenderapp.com/yoursite, or none The REST API
https://api.tenderapp.com/yoursite/mcp The site's MCP server, and nothing else

A token issued without a resource works for the REST API only. A resource the site doesn't serve gets an error (invalid_target at the token endpoint).

Token lifetimes and refreshing

Starts with Lasts
Authorization code tender_code_ 5 minutes, once
Access token tender_oat_ 1 hour
Refresh token tender_ort_ 30 days, once

When the access token expires (a 401 with error="invalid_token"), use the refresh token to get a new pair:

curl https://yoursite.tenderapp.com/oauth/token \
     -d grant_type=refresh_token \
     -d refresh_token=tender_ort_cOIo4UxpW2YQKwDorRuapHWD1RhL9FmjSIJ5B5LSvus \
     -d client_id=tender_client_99eed931a1fe53c823ad644689cc3a1b
{
  "access_token": "tender_oat_kXKXLv0hsnYQrNXMqs7LuTahUjPbU62_kmanhgs8NMc",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tender_ort_0QDWtI6ePA0kgor2kA-8HdOnpiz9dDYxOQWrOYdTlSY",
  "scope": "read write"
}

Refresh tokens rotate: each one works once, and the answer has a new one. Save it before you use the new access token. You can add scope to a refresh to get an access token with less (read from a read write authorization); the new refresh token keeps the full scope.

Using a refresh token twice ends the authorization. Tender takes a used refresh token coming back as a sign it was copied, and revokes every token from that authorization:

HTTP/1.1 400 Bad Request

{"error":"invalid_grant","error_description":"The refresh token has been revoked"}

The same happens if a code is used twice. The person then has to authorize your application again. If your application might refresh from two places at once, make sure only one does.

A refresh also fails once the person is no longer on the site's support staff.

Token endpoint errors are 400 with error and error_description (invalid_grant, invalid_request, invalid_scope, invalid_target, unsupported_grant_type), or 401 with invalid_client when client authentication fails.

Revoking tokens

When a person disconnects your application, or you no longer need its tokens, revoke them (RFC 7009):

curl https://yoursite.tenderapp.com/oauth/revoke \
     -d token=tender_ort_0QDWtI6ePA0kgor2kA-8HdOnpiz9dDYxOQWrOYdTlSY \
     -d client_id=tender_client_99eed931a1fe53c823ad644689cc3a1b
  • Revoking a refresh token ends the whole authorization: its access tokens stop working too.
  • Revoking an access token stops just that one.
  • The answer is 200 OK whether or not the token existed. Authenticate as you do at the token endpoint.

Where people see your application

Each supporter can see the applications they've allowed on their API tokens page (Account settings › API keys › Manage your API tokens), under Authorized applications: its name, the address it sends people back to, its access, when they allowed it and when it was last used. Revoke access there cuts off all of your application's tokens for that person; it'll need their approval again.

Authorized applications on a supporter's API tokens page

Rate limits

Per site and per address that calls them:

Endpoint Limit
/oauth/register 10 registrations an hour
/oauth/token and /oauth/revoke together 60 requests a minute

Over the limit, you get 429 Too Many Requests with a Retry-After header in seconds. Register once per site and keep the client_id; refresh when the access token expires, not before every request.

Checklist

  • Use HTTPS for everything (Tender refuses OAuth requests over plain HTTP).
  • Always use PKCE with S256, and a fresh state for each authorization.
  • Keep tokens and the client secret out of URLs and logs.
  • Store the new refresh token from every refresh.
  • Ask for read unless you need to write.

More from the knowledge base