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 theirtenderapp.comaddress for this.) - The API at
https://api.tenderapp.com/yoursiteis 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 itsclient_id, and PKCE protects the exchange.client_secret_basicorclient_secret_post: a confidential client, such as a web service. The registration response includes aclient_secret(startingtender_cs_, shown only this once, and it doesn't expire). Send it with HTTP Basic authentication (client_id:client_secret) or asclient_idandclient_secretfields 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.1or[::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 nojavascript:,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. resourceis 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 OKwhether 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.

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
statefor each authorization. - Keep tokens and the client secret out of URLs and logs.
- Store the new refresh token from every refresh.
- Ask for
readunless you need to write.
