Skip to content

Latest commit

 

History

History
881 lines (690 loc) · 42 KB

File metadata and controls

881 lines (690 loc) · 42 KB

Integration Guide

Architecture, credential lifecycle, and flows for the Appmax AppStore integration.


Glossary of Identifiers

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

app_id Quick Reference

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

Authentication & Credentials

Two Credential Tiers

  1. 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
  2. Merchant-level credentials (per-installation client_id / client_secret)

    • Scope: full API access (customers, orders, payments, refunds, etc.)
    • Generated during installation, stored in the installations table
    • Unique per merchant

Credential Source & Storage

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.

Bootstrap Order (Appmax Flow as of 2026-03-20)

This repository intentionally uses a two-phase setup:

  1. 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
  2. Phase 2 — activate Appmax credentials
    • wait for Appmax to email APPMAX_CLIENT_ID, APPMAX_CLIENT_SECRET, APPMAX_APP_ID_UUID, and APPMAX_APP_ID_NUMERIC
    • fill .env
    • rerun the install/bootstrap command

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.

OAuth2 Token Flow

  • Grant type: client_credentials (no refresh tokens)
  • TTL: expires_in seconds (typically 3600)
  • Endpoint: POST {AUTH_URL}/oauth2/token

Token Caching Strategy

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


Installation Flow

The installation flow has two concurrent paths that both lead to an Installation record being upserted. Both happen during the same installation event.

Why Two Paths?

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.

Sequence Diagram

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}            |
      |                     |------------------------------>|

Key Implementation Details

  • Race condition handling: Both paths call installSvc.Upsert() which is idempotent by external_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_id against APPMAX_APP_ID_UUID (from cached state)
    • Path B validates app_id against APPMAX_APP_ID_NUMERIC (from request body)
    • Path B validates client_key == external_key (security check)
  • 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


Installation & Health Check Deep Dive

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.

Step-by-Step Flow

Step 1: Get App-Level OAuth Token

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


Step 2: Authorize Installation (Get Hash)

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_id here is the UUID form (APPMAX_APP_ID_UUID), not numeric.
  • url_callback must be publicly reachable by Appmax (use NGROK_URL for local dev).
  • external_key is developer-provided and becomes the merchant lookup key.

Our app then:

  1. Caches {AppID, ExternalKey} in Redis at key install:{hash} with TTL = 1 hour.
  2. Redirects the merchant browser to {ADMIN_URL}/appstore/integration/{hash}.

Postman variable output: HASH = data.token


Step 3: Merchant Authorizes in Appmax Admin Panel (Browser)

Actor: Merchant URL: {ADMIN_URL}/appstore/integration/{hash}

The merchant:

  1. Logs into the Appmax admin panel (sandbox or production).
  2. Fills in the store name and selects the company.
  3. 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).

Step 4: Generate Merchant Credentials (Path A Only)

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


Step 5: Health Check POST (Path B) — The Critical Step

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"
}
Field Mapping
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
Validation Chain (in order)

Our controller (install_controller.go:196-243) validates in this exact order:

  1. Parse JSON body — If binding fails: 400 {"message": "invalid request body"}
  2. All 5 fields present — If any empty: 400 {"message": "app_id, external_key, client_key, merchant_client_id and merchant_client_secret are required"}
  3. app_id matches config — Compares body.AppID against APPMAX_APP_ID_NUMERIC env var. If mismatch: 400 {"message": "invalid app_id"}
  4. client_key equals external_key — Compares body.ClientKey against body.ExternalKey. If mismatch: 400 {"message": "invalid client_key"}
  5. Upsert installation — Creates or updates the Installation record by external_key. If DB error: 500 {"message": "internal server error"}
  6. Return success200 {"external_id": "uuid"}
Important: app_id is Numeric, Not 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.


Step 6: Get Merchant Token (For API Calls)

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


Race Condition: Path A vs Path B

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):

  1. Reads Redis cache for install:{token}
  2. 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.
  3. Cache hit: consumes the entry (deletes it via Forget — the hash can only be used once), then validates app_id in the cached state against APPMAX_APP_ID_UUID.
  4. Calls /app/client/generate with the hash to obtain merchant credentials.
  5. 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.
  6. 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.


Common Failures & Troubleshooting

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

Debugging Tips

  • 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 installations table by external_key to verify the record was created.

Testing with Postman

The Postman collection (docs/postman/Appmax - Full Integration Suite.postman_collection.json) has two flows for testing:

Appmax Endpoints Flow (Full Integration)

Matches Steps 1-6 above. Run in order:

  1. Get App Token — Sets APP_TOKEN; auto-generates EXTERNAL_KEY = "postman-{timestamp}"
  2. Authorize Installation — Sets HASH; uses APPMAX_APP_ID_UUID
  3. [BROWSER] — Open the URL printed in the test output
  4. Generate Merchant Credentials — Sets MERCHANT_CLIENT_ID, MERCHANT_CLIENT_SECRET
  5. Health Check POST — Simulates Appmax callback; uses APP_ID_NUMERIC
  6. Get Merchant Token — Sets MERCHANT_TOKEN

Localhost Flow (Local Testing)

Tests the install flow through your local app endpoints (not Appmax directly):

  1. Start Install (GET) — Pre-request generates INSTALLATION_KEY = "local-install-{timestamp}". Calls GET /install/start?app_id={UUID}&external_key={key}. Expects 302 redirect. Test script extracts HASH from the Location header and renders a browser visualization with the authorization URL.
  2. [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.
  3. Callback POST (upsert) — Simulates a health check POST for the existing INSTALLATION_KEY using {{APP_ID_NUMERIC}}. Expects 200 with external_id.
  4. Callback POST (new key) — Same request but with "new-install-{{$timestamp}}" as external_key and client_key. Tests creating a brand new installation.
  5. Sync Merchant Token — Calls GET /installations/{key}/merchant/token. Sets MERCHANT_TOKEN, MERCHANT_CLIENT_ID, MERCHANT_CLIENT_SECRET from the database (not from Appmax directly).

Validation tests (in the Validations subfolder):

  • GET /install/start without app_id → 400
  • GET /integrations/.../callback/install?token=token-invalido → 200 (graceful, not error)
  • POST /integrations/.../callback/install with wrong app_id → 400

Variable Chain (Appmax Endpoints Flow)

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

Variable Chain (Localhost Flow)

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

Checkout Flow

All checkout endpoints are protected by the MerchantContext middleware, which:

  1. Extracts {key} from the route parameter
  2. Looks up Installation by external_key
  3. Returns 404 if not found
  4. Injects the Installation into the request context

Sequence: Create Order and Pay

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}        |                              |
  |<--------------------------|                              |

Shortcut: Combined Create + Pay

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.

Order Persistence (Best-Effort)

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.

Error Handling Patterns

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


Webhook Processing

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.

Event-to-Status Mapping

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

No-Op Events (Acknowledged but not processed)

PascalCase snake_case
CustomerCreated customer_created
CustomerInterested customer_interested
CustomerContacted customer_contacted
SubscriptionCancellationEvent subscription_cancelation
SubscriptionDelayedEvent subscription_delayed

Deduplication

Before processing, the service checks for an existing WebhookEvent with:

  • Same event name
  • 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 1

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

Processing Flow

  1. Persist WebhookEvent record immediately (audit trail)
  2. Check for duplicates
  3. If no-op event: mark processed, return
  4. Look up status mapping; if unknown event or no order_id: mark processed with warning
  5. Find local Order by appmax_order_id
  6. Update Order.Status to mapped value
  7. Mark WebhookEvent as processed

Implementation: app/http/controllers/webhook_controller.go, app/services/webhook_service.go


Sandbox vs Production URLs

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.


Environment Variables Reference

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


Gateway HTTP Client

Retry Policy

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

Upstream Error Extraction

When Appmax returns an unexpected status code, the client:

  1. Reads the full response body
  2. Extracts trace headers (CF-Ray, X-Request-Id, X-Trace-Id) for debugging
  3. Parses the JSON body for message or errors.message fields
  4. Wraps everything in an upstreamHTTPError that implements HTTPStatus() and UpstreamMessage()

Controllers use UpstreamErrorStatus() and UpstreamErrorMessage() helpers to extract the status code and message from these errors for the HTTP response.


Cache Key Patterns

Key Pattern Storage TTL Contains
install:{hash} Redis 1 hour JSON: {AppID, ExternalKey}
merchant_token:{installation_id} Redis expires_in - 60s Bearer token string

Redis Unavailability Behavior

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. If cache.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.

How to Extend the Project

Adding a New Checkout Endpoint

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):

  1. Define the route in routes/api.go inside checkoutGroup:

    checkoutGroup.Post("/void", controllers.NewCheckoutController().Void)
  2. Create request struct in app/http/requests/ (if the endpoint accepts a body):

    type VoidRequest struct {
        OrderID int `json:"order_id" binding:"required"`
    }
  3. Create response struct in app/http/responses/:

    type CheckoutVoidResponse struct {
        Message string `json:"message"`
    }
  4. Add method to CheckoutService interface in app/services/ports.go:

    ProcessVoid(ctx context.Context, inst *models.Installation, orderID int) error
  5. 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)
  6. If calling a new Appmax endpoint: Add to the Gateway interface in app/gateway/appmax/contracts/gateway.go, add request/response types in app/gateway/appmax/contracts/types.go, and implement in the appropriate file under app/gateway/appmax/.

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

Adding a New Webhook Event

The webhook system is generic. To add a new event:

  1. Add to webhookStatusMap in app/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).

  2. If the event should be acknowledged but not processed (e.g., customer events), add it to knownNoOpEvents instead:

    "NewNoOpEvent":    true,
    "new_no_op_event": true,
  3. 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.

Adding a New Payment Method

  1. Gateway layer (app/gateway/appmax/):

    • Add request/response types in contracts/types.go
    • Add method to Gateway interface in contracts/gateway.go
    • Implement in payments.go
  2. Service layer (app/services/):

    • Add Process{Method} to the CheckoutService interface in ports.go
    • Implement in checkout_service.go following the same pattern as ProcessCreditCard: auto-create customer+order if IDs are zero, call gateway, persist order best-effort
  3. Controller layer (app/http/controllers/checkout_controller.go):

    • Add Pay{Method} handler method
    • Add response struct in app/http/responses/
  4. Route (routes/api.go):

    • Add checkoutGroup.Post("/pay/{method}", ...)
  5. Tests (tests/unit/services/, tests/unit/controllers/)