Architecture, credential lifecycle, and flows for the Appmax AppStore integration.
| Field | Format | Source | Used Where |
|---|---|---|---|
APPMAX_APP_ID_UUID |
UUID string | Appmax credential email after first URL registration | GET /install/start validation, POST /app/authorize request |
APPMAX_APP_ID_NUMERIC |
Integer | Appmax credential email after first URL registration | POST /integrations/appmax/callback/install health check validation |
external_key |
String | Developer-provided | Merchant lookup key, unique per installation, used in all {key} routes |
client_key |
String | Appmax health check POST | Must equal external_key (security validation in callback) |
external_id |
UUID string | Generated by app | Returned to Appmax as installation confirmation |
merchant_client_id |
String | Appmax (OAuth or POST) | Merchant-level OAuth client ID, per installation |
merchant_client_secret |
String | Appmax (OAuth or POST) | Merchant-level OAuth client secret, per installation |
customer_id |
Integer | Appmax /v1/customers |
Required to create orders |
order_id |
Integer | Appmax /v1/orders |
Required for payments, refunds, tracking, upsell |
appmax_order_id |
Integer | Appmax response | Stored locally, unique index, webhook deduplication key |
The same app has two identifiers in Appmax. Using the wrong form in the wrong context is the most common integration mistake.
| Context | Env Variable | Form | Example |
|---|---|---|---|
GET /install/start query param |
APPMAX_APP_ID_UUID |
UUID | a1b2c3d4-e5f6-7890-abcd-ef1234567890 |
POST /app/authorize request body |
APPMAX_APP_ID_UUID |
UUID | a1b2c3d4-e5f6-7890-abcd-ef1234567890 |
POST /integrations/.../callback body |
APPMAX_APP_ID_NUMERIC |
Integer | 889 |
-
App-level credentials (
APPMAX_CLIENT_ID/APPMAX_CLIENT_SECRET)- Scope: installation flow only (
/app/authorize,/app/client/generate) - Shared across all installations
- Configured via environment variables after Appmax emails them
- Scope: installation flow only (
-
Merchant-level credentials (per-installation
client_id/client_secret)- Scope: full API access (customers, orders, payments, refunds, etc.)
- Generated during installation, stored in the
installationstable - Unique per merchant
| Credential | Source | Storage | Scope |
|---|---|---|---|
APPMAX_CLIENT_ID |
Appmax credential email after first URL registration | .env |
All installations |
APPMAX_CLIENT_SECRET |
Appmax credential email after first URL registration | .env |
All installations |
merchant_client_id |
Appmax API response | PostgreSQL | One installation |
merchant_client_secret |
Appmax API response | PostgreSQL | One installation |
App credentials are static configuration. Merchant credentials are dynamic: they are
returned by Appmax during installation (either via /app/client/generate in Path A
or directly in the health check POST in Path B) and persisted in the installations
table per merchant.
This repository intentionally uses a two-phase setup:
- Phase 1 — bootstrap without
APPMAX_*- set
NGROK_AUTHTOKEN - optionally set a stable
NGROK_URL - run the project once to obtain the public URLs
- register those URLs in the first Appmax App Store setup
- set
- Phase 2 — activate Appmax credentials
- wait for Appmax to email
APPMAX_CLIENT_ID,APPMAX_CLIENT_SECRET,APPMAX_APP_ID_UUID, andAPPMAX_APP_ID_NUMERIC - fill
.env - rerun the install/bootstrap command
- wait for Appmax to email
Reason: as of March 20, 2026, App Store URL changes are not automatically replicated into the Appmax sandbox. If those URLs change after the first setup, contact desenvolvimento@appmax.com.br and ask Appmax to replicate the update.
- Grant type:
client_credentials(no refresh tokens) - TTL:
expires_inseconds (typically 3600) - Endpoint:
POST {AUTH_URL}/oauth2/token
| Token Type | Storage | Key | TTL |
|---|---|---|---|
| App token | In-memory (mutex) | Single cached value | expires_in - 60s buffer |
| Merchant | Redis | merchant_token:{installation_id} |
expires_in - 60s buffer |
Both token types use the same 60-second pre-expiry buffer. The buffer ensures a cached token is always valid for the duration of an API call. Without it, a token with 1 second remaining could expire mid-request (Appmax API calls typically take 1-3 seconds). If the buffer is too aggressive, token refresh frequency increases; if too small, you risk expired tokens during requests. 60 seconds is a safe default for typical API call durations.
Implementation: app/services/token_manager.go
The installation flow has two concurrent paths that both lead to an Installation record
being upserted. Both happen during the same installation event.
Path B (health check POST) is the authoritative path. Appmax always sends it server-to-server after the merchant authorizes. It delivers merchant credentials directly in the POST body. This path alone is sufficient to complete an installation.
Path A (browser redirect) is a convenience path. It gives the merchant's browser
a redirect back to our app after authorization, so we can show a success page. Path A
also generates merchant credentials via /app/client/generate, but if Path B already
completed, Path A detects the existing installation and skips credential generation.
Both paths produce the same merchant credentials (issued by Appmax for the same
installation). The Upsert operation is idempotent by external_key, so the final
Installation record is identical regardless of which path runs first, second, or alone.
Merchant Browser Our App Appmax
| | |
| GET /install/start | |
| ?app_id=UUID | |
| &external_key=KEY | |
|-------------------->| |
| | POST /oauth2/token |
| | (app credentials) |
| |------------------------------>|
| | <-- access_token |
| | |
| | POST /app/authorize |
| | (app_id, external_key, |
| | url_callback) |
| |------------------------------>|
| | <-- hash |
| | |
| | Cache {AppID, ExternalKey} |
| | at key "install:{hash}" |
| | TTL = 1 hour |
| | |
| 302 Redirect to | |
| {ADMIN_URL}/appstore/integration/{hash} |
|<--------------------| |
| | |
| (Merchant authorizes in Appmax admin panel) |
| | |
| | |
| |===== PATH A (Browser) ===== |
| GET /integrations/ | |
| appmax/callback/ | |
| install?token=HASH | |
|-------------------->| |
| | Read cache "install:{hash}" |
| | Validate AppID == UUID |
| | |
| | POST /app/client/generate |
| | (hash) |
| |------------------------------>|
| | <-- client_id, client_secret |
| | |
| | Upsert Installation |
| | (idempotent by external_key) |
| | |
| 200 {external_id} | |
|<--------------------| |
| | |
| |===== PATH B (Health Check) ===|
| | |
| | POST /integrations/appmax/ |
| | callback/install |
| |<------------------------------|
| | Body: |
| | app_id (NUMERIC) |
| | external_key |
| | client_key |
| | client_id |
| | client_secret |
| | |
| | Validate: |
| | app_id == APPMAX_APP_ID_NUMERIC
| | client_key == external_key |
| | |
| | Upsert Installation |
| | (idempotent by external_key) |
| | |
| | 200 {external_id} |
| |------------------------------>|
- Race condition handling: Both paths call
installSvc.Upsert()which is idempotent byexternal_key. If Path B (POST) arrives first, Path A (GET) detects the existing installation and returns success without calling/app/client/generate. - Cache miss in Path A: If the Redis cache entry is already consumed or expired when the browser redirect arrives, the app checks for an existing installation (created by Path B).
- Validation:
- Path A validates
app_idagainstAPPMAX_APP_ID_UUID(from cached state) - Path B validates
app_idagainstAPPMAX_APP_ID_NUMERIC(from request body) - Path B validates
client_key == external_key(security check)
- Path A validates
- ExternalID generation: A new UUID is generated via
uuid.New()only on first create, not on update.
Implementation: app/http/controllers/install_controller.go, app/services/install_service.go
This section walks through every step of the installation flow with exact payloads, matching the Postman collection order. The health check (Step 5) is where most integration issues occur.
Actor: Our App
Endpoint: POST {AUTH_URL}/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
client_id={APPMAX_CLIENT_ID}
client_secret={APPMAX_CLIENT_SECRET}
Response (200):
{
"access_token": "eyJhbGciOiJS...",
"token_type": "Bearer",
"expires_in": 3600
}Postman variable output: APP_TOKEN = access_token
This token is used in Steps 2 and 4. Our app caches it in memory with a 60-second pre-expiry buffer (see Token Caching Strategy above).
Actor: Our App
Endpoint: POST {API_URL}/app/authorize
Headers: Authorization: Bearer {APP_TOKEN}, Content-Type: application/json
{
"app_id": "{APPMAX_APP_ID_UUID}",
"external_key": "{EXTERNAL_KEY}",
"url_callback": "{APP_PUBLIC_URL}/integrations/appmax/callback/install"
}Response (200/201):
{
"data": {
"token": "a1b2c3d4e5f6..."
}
}Key points:
app_idhere is the UUID form (APPMAX_APP_ID_UUID), not numeric.url_callbackmust be publicly reachable by Appmax (useNGROK_URLfor local dev).external_keyis developer-provided and becomes the merchant lookup key.
Our app then:
- Caches
{AppID, ExternalKey}in Redis at keyinstall:{hash}with TTL = 1 hour. - Redirects the merchant browser to
{ADMIN_URL}/appstore/integration/{hash}.
Postman variable output: HASH = data.token
Actor: Merchant
URL: {ADMIN_URL}/appstore/integration/{hash}
The merchant:
- Logs into the Appmax admin panel (sandbox or production).
- Fills in the store name and selects the company.
- Clicks "Salvar" (Save).
After saving, Appmax triggers two things simultaneously:
- Path A: Redirects the merchant's browser back to the callback URL with
?token={hash}. - Path B: Sends a health check POST to the callback URL (Step 5 below).
Actor: Our App (triggered by the browser redirect in Path A)
Endpoint: POST {API_URL}/app/client/generate
Headers: Authorization: Bearer {APP_TOKEN}, Content-Type: application/json
{
"token": "{HASH}"
}Response (200/201):
{
"data": {
"client": {
"client_id": "ac_7d8e9f0g1h",
"client_secret": "sec_2a3b4c5d6e7f"
}
}
}Key points:
- This call may return 504 (Keycloak timeout), especially in sandbox. Retry if needed.
- If Path B (health check POST) has already completed, this step is skipped — our app detects the existing installation and returns success directly.
Postman variable output: MERCHANT_CLIENT_ID, MERCHANT_CLIENT_SECRET
Actor: Appmax (automatic, not triggered by us)
Endpoint: POST {OUR_APP_URL}/integrations/appmax/callback/install
Content-Type: application/json
This is what Appmax sends to our callback URL after the merchant authorizes the installation. It delivers the merchant credentials directly.
Exact request body from Appmax:
{
"app_id": "889",
"external_key": "merchant-store-123",
"client_key": "merchant-store-123",
"client_id": "ac_7d8e9f0g1h",
"client_secret": "sec_2a3b4c5d6e7f"
}What we must respond (200 OK):
{
"external_id": "550e8400-e29b-41d4-a716-446655440000"
}| JSON Key | Go Struct Field | DB Column | Description |
|---|---|---|---|
app_id |
AppID |
app_id |
Numeric form (e.g., "889"), NOT the UUID |
external_key |
ExternalKey |
external_key |
Developer-provided merchant identifier |
client_key |
ClientKey |
(not stored) | Security check: must equal external_key |
client_id |
MerchantClientID |
merchant_client_id |
OAuth client ID for merchant API calls |
client_secret |
MerchantClientSecret |
merchant_client_secret |
OAuth client secret for merchant API calls |
Response field:
| JSON Key | Source | Description |
|---|---|---|
external_id |
uuid.New().String() |
Generated UUID on first create, reused on update |
Our controller (install_controller.go:196-243) validates in this exact order:
- Parse JSON body — If binding fails:
400 {"message": "invalid request body"} - All 5 fields present — If any empty:
400 {"message": "app_id, external_key, client_key, merchant_client_id and merchant_client_secret are required"} app_idmatches config — Comparesbody.AppIDagainstAPPMAX_APP_ID_NUMERICenv var. If mismatch:400 {"message": "invalid app_id"}client_keyequalsexternal_key— Comparesbody.ClientKeyagainstbody.ExternalKey. If mismatch:400 {"message": "invalid client_key"}- Upsert installation — Creates or updates the
Installationrecord byexternal_key. If DB error:500 {"message": "internal server error"} - Return success —
200 {"external_id": "uuid"}
This is the most common source of confusion. Appmax sends the numeric app ID
in the health check POST (e.g., "889"), but the installation initiation
(GET /install/start) uses the UUID form. They are configured separately:
APPMAX_APP_ID_UUID— Used in Step 2 (/app/authorize)APPMAX_APP_ID_NUMERIC— Validated in Step 5 (health check POST)
Both values come from the Appmax credential email sent after the first URL registration and must be set in environment variables before the full Appmax installation flow can succeed.
Actor: Our App
Endpoint: POST {AUTH_URL}/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
client_id={MERCHANT_CLIENT_ID}
client_secret={MERCHANT_CLIENT_SECRET}
Response (200):
{
"access_token": "eyJhbGciOiJS...",
"token_type": "Bearer",
"expires_in": 3600
}This merchant token is used for all subsequent API calls (customers, orders, payments).
Our app caches it in Redis at key merchant_token:{installation_id} with a 60-second
pre-expiry buffer.
Postman variable output: MERCHANT_TOKEN = access_token
Both paths happen concurrently during the same installation event:
Timeline:
t0 Merchant clicks "Salvar" in Appmax admin panel
t1 Appmax sends health check POST (Path B) to callback URL
t2 Appmax redirects merchant's browser (Path A) to callback URL with ?token=
t3 Whichever path arrives first creates the Installation record
t4 The second path detects the existing record and returns success
Path B (POST) typically arrives first because it's a server-to-server call, while Path A requires a browser redirect.
How Path A handles it (exact code flow in install_controller.go:91-157):
- Reads Redis cache for
install:{token} - Cache miss (consumed, expired, or invalid token): returns
200 {"message": "installation confirmed"}immediately. Does not check the database. Trusts that Path B will handle or has already handled the installation. - Cache hit: consumes the entry (deletes it via
Forget— the hash can only be used once), then validatesapp_idin the cached state againstAPPMAX_APP_ID_UUID. - Calls
/app/client/generatewith the hash to obtain merchant credentials. - If generate fails (e.g., 504 Keycloak timeout): checks the database for an
existing installation by
external_key. If found with credentials (Path B already completed), returns success. If not found, returns 502. - If generate succeeds: upserts the installation with credentials, returns
200 {"external_id": "..."}.
Both paths are idempotent: installSvc.Upsert() uses external_key as the
deduplication key. On update, only merchant_client_id, merchant_client_secret, and
installed_at are refreshed. The app_id and external_id fields are immutable
after initial creation.
| Symptom | Cause | Fix |
|---|---|---|
400 "invalid app_id" |
APPMAX_APP_ID_NUMERIC env var doesn't match |
Check the numeric app ID from the Appmax credential email |
400 "invalid client_key" |
client_key != external_key in Appmax POST |
Usually an Appmax-side config issue; contact support |
| Health check POST never arrives | Callback URL not reachable from Appmax servers | Ensure NGROK_URL or APP_URL is publicly accessible |
| Step 5 Postman test fails "NGROK_URL is set" | NGROK_URL collection variable is empty |
Set NGROK_URL in the Postman collection variables to your ngrok tunnel URL (e.g., https://foo.ngrok-free.app) — see Postman Variables |
504 on POST /app/client/generate |
Keycloak timeout (common in sandbox) | Retry; Path B (POST) may have already succeeded |
| Installation created but creds empty | Health check POST failed; Path A also failed | Check app logs for callback errors |
400 "token is required" on GET |
Browser redirect arrived without ?token= param |
Appmax redirect URL misconfigured |
| Duplicate installations | Different external_key values used |
Ensure the same external_key is used consistently |
- Check logs for
[install:debug]: The Callback handler logs the raw request body and headers for every health check POST. - Check logs for
[install]: Shows parsed fields after binding. - Redis cache: Look for
install:{hash}keys to verify state caching. - Database: Query
installationstable byexternal_keyto verify the record was created.
The Postman collection (docs/postman/Appmax - Full Integration Suite.postman_collection.json)
has two flows for testing:
Matches Steps 1-6 above. Run in order:
- Get App Token — Sets
APP_TOKEN; auto-generatesEXTERNAL_KEY="postman-{timestamp}" - Authorize Installation — Sets
HASH; usesAPPMAX_APP_ID_UUID - [BROWSER] — Open the URL printed in the test output
- Generate Merchant Credentials — Sets
MERCHANT_CLIENT_ID,MERCHANT_CLIENT_SECRET - Health Check POST — Simulates Appmax callback; uses
APP_ID_NUMERIC - Get Merchant Token — Sets
MERCHANT_TOKEN
Tests the install flow through your local app endpoints (not Appmax directly):
- Start Install (GET) — Pre-request generates
INSTALLATION_KEY = "local-install-{timestamp}". CallsGET /install/start?app_id={UUID}&external_key={key}. Expects 302 redirect. Test script extractsHASHfrom theLocationheader and renders a browser visualization with the authorization URL. - [BROWSER] — Open the authorization URL printed by Step 1 in your browser. Log in to Appmax sandbox, fill store name, select company, click "Salvar". After authorization, Appmax sends the health check POST automatically to your callback URL.
- Callback POST (upsert) — Simulates a health check POST for the existing
INSTALLATION_KEYusing{{APP_ID_NUMERIC}}. Expects 200 withexternal_id. - Callback POST (new key) — Same request but with
"new-install-{{$timestamp}}"as external_key and client_key. Tests creating a brand new installation. - Sync Merchant Token — Calls
GET /installations/{key}/merchant/token. SetsMERCHANT_TOKEN,MERCHANT_CLIENT_ID,MERCHANT_CLIENT_SECRETfrom the database (not from Appmax directly).
Validation tests (in the Validations subfolder):
GET /install/startwithoutapp_id→ 400GET /integrations/.../callback/install?token=token-invalido→ 200 (graceful, not error)POST /integrations/.../callback/installwith wrongapp_id→ 400
EXTERNAL_KEY ─── (Step 1 pre-request) ──> used in Steps 2, 5
APP_TOKEN ────── (Step 1 response) ────> used in Steps 2, 4 auth headers
HASH ─────────── (Step 2 response) ────> used in Step 3 browser URL, Step 4 body
MERCHANT_CLIENT_ID ── (Step 4 response) ──> used in Step 5 body, Step 6 auth
MERCHANT_CLIENT_SECRET (Step 4 response) ──> used in Step 5 body, Step 6 auth
MERCHANT_TOKEN ─ (Step 6 response) ────> used in all checkout/payment APIs
INSTALLATION_KEY ── (Step 1 pre-request) ──> used in Steps 3, 5
EXTERNAL_KEY ────── (Step 1 pre-request) ──> alias for INSTALLATION_KEY
HASH ─────────────── (Step 1 Location header) ──> browser authorization URL
MERCHANT_CLIENT_ID ── (Step 5 DB response) ──> used in checkout requests
MERCHANT_CLIENT_SECRET (Step 5 DB response) ──> used in checkout requests
MERCHANT_TOKEN ───── (Step 5 DB response) ──> used in all checkout/payment APIs
All checkout endpoints are protected by the MerchantContext middleware, which:
- Extracts
{key}from the route parameter - Looks up
Installationbyexternal_key - Returns 404 if not found
- Injects the
Installationinto the request context
Client Our App Appmax API
| | |
| POST /checkout/{key}/order| |
| {customer, order} | |
|-------------------------->| |
| | Get merchant token (Redis) |
| | POST /v1/customers |
| |----------------------------->|
| | <-- customer_id |
| | POST /v1/orders |
| |----------------------------->|
| | <-- order_id |
| 200 {customer_id, | |
| order_id} | |
|<--------------------------| |
| | |
| POST /checkout/{key}/ | |
| pay/credit-card | |
| {customer_id, order_id, | |
| payment} | |
|-------------------------->| |
| | POST /v1/payments/credit-card
| |----------------------------->|
| | <-- payment result |
| | Persist Order (best-effort) |
| 200 {order_id, status, | |
| upsell_hash} | |
|<--------------------------| |
The pay/credit-card, pay/pix, and pay/boleto endpoints accept optional customer_id /
order_id. If omitted (or zero), the endpoint automatically creates the customer and order first
using the customer and order objects from the request body.
Orders are persisted best-effort: if the database write fails, the payment response is still returned to the client, and a warning is logged.
Why this is intentional: When a payment succeeds at Appmax, money has already moved. Failing the HTTP response because a local DB write failed would tell the client their payment failed when it actually succeeded — an unrecoverable state. Losing the local order record is recoverable: Appmax will send a webhook with the order status, and the record can be created or updated at that point. The design prioritizes payment accuracy over local data completeness.
| Scenario | HTTP Status | Source |
|---|---|---|
| Upstream Appmax API error | 502 | Gateway |
| Payment declined | 422 | Gateway |
| Order not found (local DB) | 404 | Repository |
| Invalid request body | 400 | Controller |
| Missing installation context | 500 | Middleware |
Implementation: app/http/controllers/checkout_controller.go, app/services/checkout_service.go
Webhooks are sent by Appmax to POST /webhooks/appmax when order status changes.
For detailed payload models (Standard, Standard+Meta, Two-Level Flat, Custom Content, Old Legacy), see webhooks.md.
| Webhook Event (PascalCase) | Webhook Event (snake_case) | Order Status |
|---|---|---|
OrderAuthorized |
order_authorized |
autorizado |
| - | order_authorized_with_delay |
autorizado |
OrderApproved |
order_approved |
aprovado |
OrderPaid |
order_paid |
aprovado |
OrderPaidByPix |
order_paid_by_pix |
aprovado |
OrderUpSold |
order_up_sold |
aprovado |
CreatedSubscription |
- | aprovado |
| - | split_orders |
aprovado |
| - | payment_authorized_with_delay |
autorizado |
OrderBilletCreated |
order_billet_created |
pendente |
OrderPixCreated |
order_pix_created |
pendente |
OrderPendingIntegration |
order_pending_integration |
pendente_integracao |
OrderIntegrated |
order_integrated |
integrado |
OrderRefund |
order_refund |
estornado |
OrderPixExpired |
order_pix_expired |
cancelado |
OrderBilletOverdue |
order_billet_overdue |
cancelado |
| - | payment_not_authorized |
cancelado |
OrderChargeBackInTreatment |
order_chargeback_in_treatment |
chargeback_em_tratativa |
| PascalCase | snake_case |
|---|---|
CustomerCreated |
customer_created |
CustomerInterested |
customer_interested |
CustomerContacted |
customer_contacted |
SubscriptionCancellationEvent |
subscription_cancelation |
SubscriptionDelayedEvent |
subscription_delayed |
Before processing, the service checks for an existing WebhookEvent with:
- Same
eventname - Same
appmax_order_id processed = true- Different
id(not self)
Equivalent query:
SELECT * FROM webhook_events
WHERE event = ? AND appmax_order_id = ? AND processed = true AND id != ?
LIMIT 1If a match is found, the current event is marked as processed and the response
{"message": "already processed"} is returned without updating any order status.
This prevents duplicate webhooks (which Appmax may send on retries) from triggering
redundant order status updates.
- Persist
WebhookEventrecord immediately (audit trail) - Check for duplicates
- If no-op event: mark processed, return
- Look up status mapping; if unknown event or no order_id: mark processed with warning
- Find local
Orderbyappmax_order_id - Update
Order.Statusto mapped value - Mark
WebhookEventas processed
Implementation: app/http/controllers/webhook_controller.go, app/services/webhook_service.go
| Service | Sandbox | Production | Env Variable |
|---|---|---|---|
| Auth | https://auth.sandboxappmax.com.br |
https://auth.appmax.com.br |
APPMAX_AUTH_URL |
| API | https://api.sandboxappmax.com.br |
https://api.appmax.com.br |
APPMAX_API_URL |
| Admin | https://breakingcode.sandboxappmax.com.br |
https://admin.appmax.com.br |
APPMAX_ADMIN_URL |
To switch environments, change only these 3 variables in .env. All other configuration
(credentials, app IDs, ngrok) stays the same. The .env.example ships with sandbox
values pre-filled.
| Variable | Required | Default | Description |
|---|---|---|---|
APPMAX_CLIENT_ID |
Phase 2 | - | App-level OAuth client ID sent by Appmax after the first URL registration |
APPMAX_CLIENT_SECRET |
Phase 2 | - | App-level OAuth client secret sent by Appmax after the first URL registration |
APPMAX_APP_ID_UUID |
Phase 2 | - | App UUID sent by Appmax after the first URL registration |
APPMAX_APP_ID_NUMERIC |
Phase 2 | - | App numeric ID sent by Appmax after the first URL registration |
APP_URL |
Yes* | - | Public URL of the app (fallback if no NGROK_URL) |
NGROK_URL |
No | - | Ngrok tunnel URL (takes precedence over APP_URL). Trailing slashes are trimmed at startup. See bootstrap/appmax_config.go. |
APPMAX_AUTH_URL |
No | https://auth.appmax.com.br |
OAuth2 endpoint base URL |
APPMAX_API_URL |
No | https://api.appmax.com.br |
REST API base URL |
APPMAX_ADMIN_URL |
No | https://admin.appmax.com.br |
Admin panel base URL (for install redirects) |
*APP_URL is required if NGROK_URL is not set.
APPMAX_* may remain blank during the first bootstrap used only to obtain the public URLs for the initial Appmax registration.
Configuration: bootstrap/appmax_config.go
The Appmax gateway client retries failed requests automatically. Configuration is set
in bootstrap/gateway_module.go:
| Setting | Value | Description |
|---|---|---|
RetryMax |
3 | Maximum retry attempts (total attempts = 4) |
RetryWait |
5 seconds | Fixed delay between retries |
RetryStatuses |
502, 503, 504 | HTTP status codes that trigger a retry |
Timeout |
90 seconds | Per-request HTTP timeout (default) |
Retries are also triggered on network errors (connection refused, timeout). The delay is fixed (not exponential). Context cancellation aborts the retry loop immediately.
Implementation: app/gateway/appmax/client.go, app/gateway/appmax/http.go
When Appmax returns an unexpected status code, the client:
- Reads the full response body
- Extracts trace headers (
CF-Ray,X-Request-Id,X-Trace-Id) for debugging - Parses the JSON body for
messageorerrors.messagefields - Wraps everything in an
upstreamHTTPErrorthat implementsHTTPStatus()andUpstreamMessage()
Controllers use UpstreamErrorStatus() and UpstreamErrorMessage() helpers to extract
the status code and message from these errors for the HTTP response.
| Key Pattern | Storage | TTL | Contains |
|---|---|---|---|
install:{hash} |
Redis | 1 hour | JSON: {AppID, ExternalKey} |
merchant_token:{installation_id} |
Redis | expires_in - 60s |
Bearer token string |
The app does not crash if Redis is unavailable:
- App token: Unaffected. Cached in-memory with a mutex, never touches Redis.
- Merchant token: If
cache.GetString()returns empty (miss or Redis error), the token manager fetches a fresh token from Appmax on every request. Ifcache.Put()fails, a warning is logged but the token is still returned. Performance degrades (every API call triggers a token fetch) but functionality is preserved. - Install cache: If the
install:{hash}cache entry is missing when Path A arrives, the controller falls back to checking the database for an existing installation (which Path B may have already created). If neither exists, the install fails.
The project follows a layered pattern: Route -> Controller -> Service -> Gateway.
Here is the checklist for adding a new checkout endpoint (e.g., POST /checkout/{key}/void):
-
Define the route in
routes/api.goinsidecheckoutGroup:checkoutGroup.Post("/void", controllers.NewCheckoutController().Void)
-
Create request struct in
app/http/requests/(if the endpoint accepts a body):type VoidRequest struct { OrderID int `json:"order_id" binding:"required"` }
-
Create response struct in
app/http/responses/:type CheckoutVoidResponse struct { Message string `json:"message"` }
-
Add method to
CheckoutServiceinterface inapp/services/ports.go:ProcessVoid(ctx context.Context, inst *models.Installation, orderID int) error
-
Implement the service method in
app/services/checkout_service.go:- Use
s.withMerchantToken()to get the bearer token - Call the gateway method
- Handle errors (check for
isDeclinedError)
- Use
-
If calling a new Appmax endpoint: Add to the
Gatewayinterface inapp/gateway/appmax/contracts/gateway.go, add request/response types inapp/gateway/appmax/contracts/types.go, and implement in the appropriate file underapp/gateway/appmax/. -
Add the controller method in
app/http/controllers/checkout_controller.go:- Parse request body
- Call
installationFromCtx()to get the installation - Call the service method
- Use
UpstreamErrorStatus()/UpstreamErrorMessage()for error responses
-
Write tests:
- Unit test for the service method in
tests/unit/services/using function-based mocks - Controller test in
tests/unit/controllers/if complex binding/error logic exists
- Unit test for the service method in
The webhook system is generic. To add a new event:
-
Add to
webhookStatusMapinapp/services/webhook_service.go:"NewEventName": "target_status", "new_event_name": "target_status",
Always add both PascalCase and snake_case variants (Appmax sends both forms depending on the integration).
-
If the event should be acknowledged but not processed (e.g., customer events), add it to
knownNoOpEventsinstead:"NewNoOpEvent": true, "new_no_op_event": true,
-
No other changes are needed. The controller parses the envelope, the service looks up the mapping, finds the local order, and updates the status automatically.
-
Gateway layer (
app/gateway/appmax/):- Add request/response types in
contracts/types.go - Add method to
Gatewayinterface incontracts/gateway.go - Implement in
payments.go
- Add request/response types in
-
Service layer (
app/services/):- Add
Process{Method}to theCheckoutServiceinterface inports.go - Implement in
checkout_service.gofollowing the same pattern asProcessCreditCard: auto-create customer+order if IDs are zero, call gateway, persist order best-effort
- Add
-
Controller layer (
app/http/controllers/checkout_controller.go):- Add
Pay{Method}handler method - Add response struct in
app/http/responses/
- Add
-
Route (
routes/api.go):- Add
checkoutGroup.Post("/pay/{method}", ...)
- Add
-
Tests (
tests/unit/services/,tests/unit/controllers/)