Automate credential creation with url_callback
This tutorial shows, step by step, how to build a service that receives the Appmax callback after /app/authorize and automatically generates merchant credentials with no manual intervention. By the end you will have an endpoint that turns a just-authorized merchant into an integration ready to transact.
If you want to understand the mechanism behind url_callback (exact format, contract, detailed security notes), see Installation callback. This page is a hands-on — full code and operational decisions.
What you will build
Merchant authorizes → Callback receives token → Service generates credentials → Persists → Ready to transactA single HTTP endpoint solves the automation. No manual confirmation screen, no human in the middle.
Prerequisites
- App registered on the panel with its App UUID and app credentials (
client_id/client_secret). See Create an app. - Validation URL registered on the panel (used by the health check during
/app/client/generate). See Installation. - Public HTTPS endpoint for
url_callback(in production; for dev use ngrok or similar). - A database to bind
merchant_ref↔client_id/client_secret. - A vault or environment variables to keep the app credentials out of source code.
Validation URL and url_callback are different things
- Validation URL — registered on the panel; receives the server-to-server health check during
/app/client/generate. url_callback— sent on/app/authorize; receives the browser redirect with thetoken.
This tutorial is about the second one. The first must already be working as a prerequisite.
1. Kick off the installation with url_callback
From your backend (or integrator dashboard), call /app/authorize pointing url_callback at the automation service you are about to build.
curl --location 'https://api.appmax.com.br/app/authorize' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer APP_ACCESS_TOKEN' \
--data '{
"app_id": "8f2c1d3e-5a4b-4c7d-9e1f-2a3b4c5d6e7f",
"external_key": "store_42",
"url_callback": "https://onboarding.myapp.com/appmax/callback?merchant_ref=42"
}'{
"data": {
"token": "12083w36219d223f33ecf48f2a7f5ccf143b0bc554"
}
}Carry your own identifier
Pass a merchant_ref (or any internal identifier) in the url_callback query string. It comes back untouched on the callback and lets you bind the received token to the right merchant in your database.
With the hash in hand, redirect the merchant:
| Environment | URL |
|---|---|
| Sandbox | https://breakingcode.sandboxappmax.com.br/appstore/integration/HASH |
| Production | https://admin.appmax.com.br/appstore/integration/HASH |
After the merchant authorizes, Appmax redirects to https://onboarding.myapp.com/appmax/callback?merchant_ref=42&token=12083w36219d223f33ecf48f2a7f5ccf143b0bc554.
2. Implement the callback handler
The handler has three responsibilities, in this order:
- Read
tokenandmerchant_reffrom the query string. - Swap the
tokenfor merchant credentials by calling/app/client/generate. - Persist the credentials and confirm success to the merchant.
Full example in Go 1.26, using only net/http from the stdlib (with the default http.ServeMux) plus pgx/v5 for Postgres. The four files below make a service ready to run.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
logger.Error("db.connect", "err", err)
os.Exit(1)
}
defer pool.Close()
repo := NewRepository(pool)
appmax := NewAppmaxClient(AppmaxConfig{
AuthURL: getenv("APPMAX_AUTH_URL", "https://auth.appmax.com.br/oauth2/token"),
APIURL: getenv("APPMAX_API_URL", "https://api.appmax.com.br"),
ClientID: os.Getenv("APPMAX_APP_CLIENT_ID"),
ClientSecret: os.Getenv("APPMAX_APP_CLIENT_SECRET"),
})
mux := http.NewServeMux()
mux.HandleFunc("GET /appmax/callback", handleCallback(repo, appmax))
srv := &http.Server{
Addr: ":3000",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
logger.Info("server.start", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server.error", "err", err)
stop()
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}
func handleCallback(repo *Repository, appmax *AppmaxClient) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
merchantRef := r.URL.Query().Get("merchant_ref")
if token == "" || merchantRef == "" {
http.Error(w, "missing token or merchant_ref", http.StatusBadRequest)
return
}
ctx := r.Context()
log := slog.With("merchant_ref", merchantRef)
// Idempotency: if we already provisioned this merchant, skip the swap.
if existing, err := repo.Find(ctx, merchantRef); err != nil {
log.Error("repo.find", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
} else if existing != nil {
http.Redirect(w, r, "/onboarding/done", http.StatusFound)
return
}
creds, err := appmax.GenerateClient(ctx, token)
if err != nil {
switch {
case errors.Is(err, ErrInvalidToken):
log.Warn("appmax.invalid_token")
http.Error(w, "token invalid or already consumed", http.StatusConflict)
case errors.Is(err, ErrHealthCheckFailed):
log.Error("appmax.health_check_failed")
http.Error(w, "health check failed — restart the flow", http.StatusBadGateway)
default:
log.Error("appmax.generate", "err", err)
http.Error(w, "failed to generate merchant credentials", http.StatusBadGateway)
}
return
}
if err := repo.Save(ctx, merchantRef, creds); err != nil {
// At this point Appmax's token is ALREADY consumed. There is no way to redo
// without a new /app/authorize — log loudly and return 5xx.
log.Error("repo.save", "err", err)
http.Error(w, "failed to persist credentials", http.StatusInternalServerError)
return
}
log.Info("onboarding.ok")
http.Redirect(w, r, "/onboarding/done", http.StatusFound)
}
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}To run:
go mod init onboarding && go mod tidy
export DATABASE_URL=postgres://user:pass@localhost:5432/onboarding
export APPMAX_APP_CLIENT_ID=...
export APPMAX_APP_CLIENT_SECRET=...
go run .client_secret is a sensitive credential
In production, encrypt client_secret at rest (e.g., pgcrypto, KMS, envelope encryption) or store it in a secret vault (AWS Secrets Manager, Vault) and keep only the reference in the table.
3. Use the merchant credentials
With credentials persisted, your app transacts on behalf of the merchant by authenticating at /oauth2/token with the merchant credentials (not the app's). The returned token has transactional scope.
// merchant_token.go — fetches a MERCHANT Bearer from the repository
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
func MerchantAccessToken(ctx context.Context, repo *Repository, merchantRef string) (string, error) {
creds, err := repo.Find(ctx, merchantRef)
if err != nil {
return "", err
}
if creds == nil {
return "", fmt.Errorf("merchant %q not provisioned", merchantRef)
}
form := url.Values{
"grant_type": {"client_credentials"},
"client_id": {creds.ClientID},
"client_secret": {creds.ClientSecret},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://auth.appmax.com.br/oauth2/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
return out.AccessToken, nil
}From that point on, any transactional call (customers, orders, payments) uses this token. See Authentication and Full integration example.
token, err := MerchantAccessToken(ctx, repo, "42")
if err != nil {
return err
}
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.appmax.com.br/v1/customers", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)Production tips
Idempotency
The token you receive in the callback is single-use — Appmax wipes it from the cache the moment /app/client/generate returns successfully. Two consequences:
- Retry with the same token won't work. If the merchant refreshes the page or the callback is replayed, the second
/app/client/generatereturnsInvalid token. - To make the handler idempotent, query your database by
merchant_refbefore trying the swap. If credentials exist, treat as success and redirect — that's what the example above does.
Error handling
| Situation | What to do |
|---|---|
/app/client/generate returns Invalid token | Token already consumed or expired (>1h). Ask the merchant to restart the installation — unless your database already holds credentials, in which case just finish. |
/app/client/generate returns 500 (health check failed) | Your validation URL didn't respond HTTP 200 with a UUID external_id. The token was consumed — you must redo /app/authorize. See Troubleshooting. |
Timeout calling /app/client/generate | Don't retry with the same token. Check your database for credentials — if none arrived, restart the flow. |
| Database persistence failure | You already hold client_id/client_secret in memory — persist before responding. If the insert fails, return 5xx and don't confirm to the merchant. The flow will need to restart. |
Observability
- Emit metrics per stage:
authorize_started,callback_received,credentials_generated,credentials_persisted,onboarding_failed. Tags:app_id, no PII. - Log
merchant_ref, Appmax HTTP status and error message. Never logtoken,client_secretor the app Bearer without masking. - Alert when the failure rate crosses a threshold (e.g., >5% in 15 min) — this almost always means the health check is down.
Security
- HTTPS is mandatory on
url_callback. A token in a query string over HTTP leaks. - Validate
merchant_ref— before calling/app/client/generate, confirm there is a legitimate installation attempt on your side for that ref. Helps mitigate someone replaying a leaked callback URL. - App credentials live in the onboarding service only. Use a secret vault in production.
- Short timeout on the call to
/app/client/generate(10–15s). Slow failures freeze the merchant's callback. - Don't regenerate the APP token across processes without a coordinated cache —
expires_inis 1h, so many instances hammering/oauth2/tokenis wasteful, not insecure.
Where to run the service
The handler is stateless enough to run anywhere:
- Container (ECS, Cloud Run, Kubernetes) when you already have the infra.
- Serverless function (Lambda + API Gateway, Cloud Functions) to pay only per use. The in-memory APP token cache still helps within an invocation; use a shared cache (ElastiCache/Redis) if you want to save calls to
/oauth2/token. - Integrator monolith — just add the route. Works, you just lose the domain separation.
Next steps
- Installation callback — full technical reference of the
url_callbackcontract. - App installation — the 4-step flow, including the health check.
- Authentication — app vs merchant credentials.
- Full integration example — your first transaction after credentials are provisioned.