Flavoso APIFundamentalsAuthentication

Authentication

OAuth 2.0 with PKCE: how an owner connects your app to their restaurant, and how you exchange, refresh and revoke tokens.

Overview

An app only gets access to a restaurant with its owner's consent. Flavoso uses OAuth 2.0 with authorization code (RFC 6749) and PKCE with S256 (RFC 7636), plus the iss parameter on the redirect (RFC 9207), revocation per RFC 7009 and metadata per RFC 8414.

  1. 1

    Your app

    “Connect with Flavoso”

    Your app creates state and a code_verifier and sends the browser to /oauth/authorize.

  2. 2

    Flavoso

    Sign in

    The owner signs in with their Flavoso account, with a second factor if they set one up.

  3. 3

    Flavoso

    Consent

    They pick the restaurant, read your app's name, website and scopes and press “Connect”.

  4. 4

    Browser

    Back with a code

    Flavoso redirects back to your redirect_uri with code, state and iss. The code is valid for 10 minutes and only once.

  5. 5

    Your server

    Exchange

    Your server exchanges code and code_verifier for an access token and a refresh token.

  6. 6

    Your server

    Request

    Call the API with the access token (1 hour); the refresh token gets new ones.

EndpointAddress
Authorization (browser)https://app.flavoso.com/oauth/authorize
TokenPOST https://api.flavoso.com/oauth/token
RevocationPOST https://api.flavoso.com/oauth/revoke
Metadatahttps://api.flavoso.com/.well-known/oauth-authorization-server

Authorization is a page on app.flavoso.com, because that is where the owner signs in. Token, revocation and metadata live at https://api.flavoso.com. The endpoints with every parameter are also in the reference.

Confidential and public clients

You choose the client type when you create the app:

  • Confidential (confidential): for apps with their own server. They get a client secret (flv_cs_…), which is shown exactly once and belongs on the server only. At the token endpoint they authenticate with HTTP Basic or send client_id and client_secret in the body. Real restaurants connect them only through an https redirect URI.
  • Public (public): for apps that cannot keep a secret safe, for example a POS app on a tablet or a program on the restaurant's computer. They get no secret and send client_id only. They may use loopback addresses and a custom scheme (RFC 8252), including for real restaurants.

PKCE with S256 is mandatory for both. The token endpoint accepts no calls from a browser (no CORS); a web app therefore exchanges code and refresh token on its server, which makes it a confidential client.

The client ID (flv_app_…) is not secret. “Create new client secret” in the console replaces the client secret; the old one stays valid for 24 hours, so you switch over without interruption.

Redirect URIs

After the consent, Flavoso sends the browser back to one of your app's redirect URIs. The rules for them:

  • Flavoso compares character by character with the registered addresses. The only exception is the port of loopback addresses.
  • https, without credentials in the address. Real restaurants connect any app through these.
  • Loopback (http://127.0.0.1:<port>/…, http://localhost:<port>/…, http://[::1]:<port>/…, any port, RFC 8252) and custom schemes with a dot (com.example.pos:/callback): for a public app with every restaurant, for a confidential app only with your own test restaurant. The consent screen then offers only that one.
  • No fragment (#), at most 300 characters, at most 10 addresses per app.

Requesting authorization

Your app sends the browser to https://app.flavoso.com/oauth/authorize, with these parameters:

ParameterRequiredMeaning
response_typeyesAlways code.
client_idyesYour app's client ID.
redirect_uriyesOne of the registered addresses, exactly as registered.
scopenoThe requested scopes, separated by spaces, only from your app's scopes. Without scope, you request all of your app's scopes.
stateyesA random value (up to 500 characters) that you keep and compare after the redirect. Protects against forged responses.
code_challengeyesBASE64URL(SHA-256(code_verifier)) without = at the end. You keep the code_verifier (43 to 128 characters) until the exchange.
code_challenge_methodyesAlways S256.
import { createHash, randomBytes } from "node:crypto";

// code_verifier: 43 to 128 characters of A-Z a-z 0-9 - . _ ~ (32 random bytes give 43)
const codeVerifier = randomBytes(32).toString("base64url");
// code_challenge = BASE64URL(SHA-256(code_verifier)), without "=" padding
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
const state = randomBytes(16).toString("base64url");
// Keep codeVerifier and state (e.g. in the session) until the redirect comes back.

const url = new URL("https://app.flavoso.com/oauth/authorize");
url.search = new URLSearchParams({
  response_type: "code",
  client_id: "flv_app_...",
  redirect_uri: "https://example.com/flavoso/callback",
  scope: "orders:write menu:write store:write",
  state,
  code_challenge: codeChallenge,
  code_challenge_method: "S256",
});
// Redirect the browser to url.href

The address redirects to the browser's language. The request and the redirect back look like this:

# Authorization request (one line, wrapped here for reading)
https://app.flavoso.com/oauth/authorize?response_type=code
  &client_id=flv_app_...
  &redirect_uri=https%3A%2F%2Fexample.com%2Fflavoso%2Fcallback
  &scope=orders%3Awrite%20menu%3Awrite%20store%3Awrite
  &state=STATE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256

# Connected: back at your redirect_uri
https://example.com/flavoso/callback?code=AUTHORIZATION_CODE&state=STATE&iss=https%3A%2F%2Fapi.flavoso.com

# Not connected, for example after "Cancel"
https://example.com/flavoso/callback?error=access_denied&error_description=...&state=STATE&iss=https%3A%2F%2Fapi.flavoso.com

The redirect

After “Connect”, Flavoso redirects to your redirect_uri with code, state and iss. Check that state matches the value you sent and that iss is exactly https://api.flavoso.com, the issuer from the metadata (even though the consent took place on app.flavoso.com). Only then exchange the code.

The code is valid for 10 minutes and exactly once. If it is redeemed a second time, Flavoso revokes everything that was issued with it.

With an unknown client_id, an unregistered redirect_uri or duplicate parameters, Flavoso shows an error page and does not redirect. The other errors come back as a redirect to the redirect_uri, with error, error_description, state and iss, but only for an approved app or when you are signed in as its developer. Otherwise Flavoso shows the error itself; a rejected or suspended app never gets a redirect. error can have these values:

errorMeaning
access_deniedThe user pressed “Cancel”, or may not connect this app to the chosen restaurant: for example because they do not own it, or because a confidential app only connects your test restaurant through a loopback address or a custom scheme.
invalid_requestA parameter is missing or invalid, for example state or the code_challenge.
invalid_scopeA requested scope does not exist, or your app may not request it.
unsupported_response_typeresponse_type is not code.
server_errorSomething went wrong on Flavoso's side, for example the connection could not be saved. Try again later.

Exchanging the code for tokens

You exchange the code with a POST to https://api.flavoso.com/oauth/token, body as application/x-www-form-urlencoded: grant_type=authorization_code with code, redirect_uri (the same as in the authorization request) and code_verifier. Confidential clients authenticate with HTTP Basic, public ones send client_id.

# Confidential client: HTTP Basic (or client_id and client_secret in the body)
curl https://api.flavoso.com/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code=AUTHORIZATION_CODE \
  --data-urlencode redirect_uri=https://example.com/flavoso/callback \
  -d code_verifier=$CODE_VERIFIER

# Public client (native or desktop app): client_id only, no secret
curl https://api.flavoso.com/oauth/token \
  -d grant_type=authorization_code \
  -d code=AUTHORIZATION_CODE \
  --data-urlencode redirect_uri=com.example.pos:/callback \
  -d code_verifier=$CODE_VERIFIER \
  -d client_id=$CLIENT_ID

The response contains both tokens, the granted scopes, the connection's id and the restaurant:

Response
{
  "access_token": "flv_at_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "flv_rt_...",
  "scope": "orders:write menu:write store:write reports:read",
  "connection_id": "4b3f7c1e-9a2d-4e8b-b5f6-0c1d2e3f4a5b",
  "tenant": {
    "id": "7d0e5b8a-3c41-4f2e-9a6b-1e2d3c4b5a69",
    "name": "Pizzeria Example",
    "slug": "pizzeria-example"
  }
}

Store the tokens per connection, for example under tenant.id. A token is valid for exactly one restaurant; every further restaurant gets its own connection with its own tokens.

Tokens and lifetimes

WhatFormatValid
Authorization codeflv_ac_…10 minutes, exactly once
Access tokenflv_at_…1 hour (expires_in: 3600)
Refresh tokenflv_rt_…180 days from its last use, replaced on every use
Client secretflv_cs_…until the next “Create new client secret”, the old one then for another 24 hours

Treat tokens like passwords: store them encrypted, never write them to logs, never put them into a browser or an address.

Refreshing with rotation

You get a new access token with grant_type=refresh_token and refresh_token. The response also brings a new refresh token; the old one is used up. Store the new one before you use the new access token.

If the response gets lost on the way and you send the same refresh token again within 60 seconds, you get a new pair, but only while the refresh token from the lost response is still unused. Flavoso revokes the pair from the lost response in doing so. In every other case a second redemption counts as theft: Flavoso revokes the whole family, meaning all tokens from the same consent, and the owner has to connect again.

For each connection, refresh in only one place at a time. Two processes refreshing at the same time are the most common reason for a lost connection.

With scope you narrow the new access token to fewer scopes, for example for a part of your system that only reads. The refresh token keeps all of the connection's scopes.

// A valid access token for one connection. tokenRequest() as above.
// Refresh at one place at a time per connection, and always store the NEW refresh token.
export async function accessTokenFor(conn) {
  if (Date.now() < conn.expiresAt - 60_000) return conn.accessToken;
  try {
    const t = await tokenRequest({ grant_type: "refresh_token", refresh_token: conn.refreshToken });
    conn.accessToken = t.access_token;
    conn.refreshToken = t.refresh_token; // the old one is used up
    conn.expiresAt = Date.now() + t.expires_in * 1000;
    await saveConnection(conn); // persist BEFORE using the new token
    return conn.accessToken;
  } catch (err) {
    if (err.code === "invalid_grant") await markDisconnected(conn); // the owner has to connect again
    if (err.code === "unauthorized_client") await markSuspended(conn); // app suspended: keep the tokens, retry later
    throw err;
  }
}

Revoking and disconnecting

POST https://api.flavoso.com/oauth/revoke with token revokes a token (RFC 7009); you may send token_type_hint, but Flavoso recognises the kind from the token's form. The client authenticates as at the token endpoint. Without token, Flavoso answers 400 invalid_request; if the authentication fails, 401 invalid_client. Otherwise the response is always 200, even for an unknown or already revoked token. A refresh token takes its whole family with it, an access token only itself. The connection itself remains.

To end the connection to the restaurant, your app calls DELETE /connection. Then no token of this connection works any more, the owners get an e-mail, and your webhook receives app.disconnected. For the same app and restaurant, a new consent is needed afterwards.

# Revoke a token (RFC 7009). The client authenticates as at the token endpoint;
# after that the answer is always 200. A refresh token takes its whole family with it.
curl https://api.flavoso.com/oauth/revoke \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d token=$REFRESH_TOKEN \
  -d token_type_hint=refresh_token

# End the connection to the restaurant: every token of it stops working, the owners are notified
curl -X DELETE https://api.flavoso.com/v1/connection \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Metadata

Addresses and supported methods are available machine-readable at https://api.flavoso.com/.well-known/oauth-authorization-server (RFC 8414). Many OAuth libraries read them automatically.

curl https://api.flavoso.com/.well-known/oauth-authorization-server

Errors at the token endpoint

The token endpoint answers per RFC 6749 with error and error_description, every response with Cache-Control: no-store:

errorHTTPMeaning
invalid_request400A parameter is missing, repeated or invalid.
invalid_client401Client authentication failed, with WWW-Authenticate.
invalid_grant400The code or refresh token is invalid, expired, already used or revoked, or redirect_uri or code_verifier does not match. On a refresh, this means the connection no longer exists.
unauthorized_client400The app is suspended. Its connections stay: keep the tokens and try again later.
unsupported_grant_type400This grant_type does not exist.
invalid_scope400A scope is invalid or goes beyond the connection.
temporarily_unavailable429Too many requests; Retry-After gives the wait in seconds.
server_error500Something went wrong on Flavoso's side.
{
  "error": "invalid_grant",
  "error_description": "The authorization code is invalid, expired or already used."
}

The “Connect with Flavoso” button

In your software, a button starts the flow. It leads to your server, which creates state and PKCE and redirects to Flavoso. The button shows the Flavoso name unchanged and makes no claim Flavoso has not made:

LanguageLabel
GermanMit Flavoso verbinden
EnglishConnect with Flavoso
FrenchConnecter avec Flavoso
SpanishConectar con Flavoso
ItalianCollega con Flavoso
<!-- The link goes to YOUR server, which creates state and PKCE and redirects to Flavoso -->
<a class="flavoso-connect" href="/integrations/flavoso/connect">Connect with Flavoso</a>

<style>
  .flavoso-connect {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.625rem 1rem;
    border-radius: 0.75rem;
    background: #ff5a1f;
    color: #ffffff;
    font: 600 15px/1.2 system-ui, sans-serif;
    text-decoration: none;
  }
  .flavoso-connect:hover {
    background: #e6440c;
  }
  .flavoso-connect:focus-visible {
    outline: 3px solid #ffb38f;
    outline-offset: 2px;
  }
</style>

Once connected, show in your software which restaurant (tenant.name) and since when, and offer “Disconnect” (DELETE /connection). More rules about the brand are under Review.

Many restaurants

A POS with many customers has many connections. Each has its own tokens, its own rate limit and its connection_id. Your app's webhook receives the events of all restaurants; tenant_id and connection_id in the envelope say which one it belongs to.

A restaurant that connects you a second time keeps the same connection with new scopes. After a disconnect, connecting again creates a new connection with a new connection_id.

Still unclear? Write to support@flavoso.com and include the id from X-Request-Id. It lets us find your request in the log.