Skip to content

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 transact

A 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_refclient_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 the token.

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.

bash
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"
  }'
json
{
  "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:

EnvironmentURL
Sandboxhttps://breakingcode.sandboxappmax.com.br/appstore/integration/HASH
Productionhttps://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:

  1. Read token and merchant_ref from the query string.
  2. Swap the token for merchant credentials by calling /app/client/generate.
  3. 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.

go
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
}
go
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"
)

// Typed errors the handler can branch on via errors.Is.
var (
	ErrInvalidToken      = errors.New("appmax: invalid or already consumed token")
	ErrHealthCheckFailed = errors.New("appmax: health check failed")
)

type AppmaxConfig struct {
	AuthURL      string
	APIURL       string
	ClientID     string
	ClientSecret string
}

type AppmaxClient struct {
	cfg  AppmaxConfig
	http *http.Client

	mu        sync.Mutex
	appToken  string
	appExpiry time.Time
}

func NewAppmaxClient(cfg AppmaxConfig) *AppmaxClient {
	return &AppmaxClient{
		cfg:  cfg,
		http: &http.Client{Timeout: 15 * time.Second},
	}
}

// MerchantCredentials is the pair returned by /app/client/generate.
type MerchantCredentials struct {
	ClientID     string `json:"client_id"`
	ClientSecret string `json:"client_secret"`
}

// appAccessToken fetches (or reuses) the APP Bearer via OAuth2 client_credentials.
// Renews 60s before actual expiration to avoid a race at the boundary.
func (c *AppmaxClient) appAccessToken(ctx context.Context) (string, error) {
	c.mu.Lock()
	defer c.mu.Unlock()

	if c.appToken != "" && time.Now().Before(c.appExpiry) {
		return c.appToken, nil
	}

	form := url.Values{
		"grant_type":    {"client_credentials"},
		"client_id":     {c.cfg.ClientID},
		"client_secret": {c.cfg.ClientSecret},
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.AuthURL, strings.NewReader(form.Encode()))
	if err != nil {
		return "", err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	resp, err := c.http.Do(req)
	if err != nil {
		return "", fmt.Errorf("auth request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return "", fmt.Errorf("auth status %d: %s", resp.StatusCode, body)
	}

	var out struct {
		AccessToken string `json:"access_token"`
		ExpiresIn   int    `json:"expires_in"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return "", fmt.Errorf("auth decode: %w", err)
	}

	c.appToken = out.AccessToken
	c.appExpiry = time.Now().Add(time.Duration(out.ExpiresIn-60) * time.Second)
	return c.appToken, nil
}

// GenerateClient swaps the token received on url_callback for the merchant's
// definitive credentials. The real API body accepts only {"token": ...} —
// app_id and external_key were persisted by Appmax during /app/authorize.
func (c *AppmaxClient) GenerateClient(ctx context.Context, token string) (*MerchantCredentials, error) {
	bearer, err := c.appAccessToken(ctx)
	if err != nil {
		return nil, err
	}

	body, _ := json.Marshal(map[string]string{"token": token})
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.APIURL+"/app/client/generate", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+bearer)

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, fmt.Errorf("generate request: %w", err)
	}
	defer resp.Body.Close()

	raw, _ := io.ReadAll(resp.Body)

	switch resp.StatusCode {
	case http.StatusOK:
		var out struct {
			Data struct {
				Client MerchantCredentials `json:"client"`
			} `json:"data"`
		}
		if err := json.Unmarshal(raw, &out); err != nil {
			return nil, fmt.Errorf("generate decode: %w", err)
		}
		return &out.Data.Client, nil
	case http.StatusUnprocessableEntity:
		// Invalid, expired, or already consumed hash.
		return nil, fmt.Errorf("%w: %s", ErrInvalidToken, raw)
	case http.StatusInternalServerError:
		// Usually means the health check against the validation URL failed.
		return nil, fmt.Errorf("%w: %s", ErrHealthCheckFailed, raw)
	default:
		return nil, fmt.Errorf("generate status %d: %s", resp.StatusCode, raw)
	}
}
go
package main

import (
	"context"
	"errors"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

type Repository struct {
	pool *pgxpool.Pool
}

func NewRepository(pool *pgxpool.Pool) *Repository {
	return &Repository{pool: pool}
}

type StoredCredentials struct {
	MerchantRef  string
	ClientID     string
	ClientSecret string
	CreatedAt    time.Time
}

// Find returns (nil, nil) when the merchant has not been provisioned yet.
func (r *Repository) Find(ctx context.Context, merchantRef string) (*StoredCredentials, error) {
	const q = `SELECT merchant_ref, client_id, client_secret, created_at
	           FROM merchant_credentials WHERE merchant_ref = $1`

	row := r.pool.QueryRow(ctx, q, merchantRef)
	var c StoredCredentials
	if err := row.Scan(&c.MerchantRef, &c.ClientID, &c.ClientSecret, &c.CreatedAt); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, nil
		}
		return nil, err
	}
	return &c, nil
}

// Save is idempotent — ON CONFLICT keeps the first successful provisioning.
func (r *Repository) Save(ctx context.Context, merchantRef string, creds *MerchantCredentials) error {
	const q = `INSERT INTO merchant_credentials (merchant_ref, client_id, client_secret, created_at)
	           VALUES ($1, $2, $3, NOW())
	           ON CONFLICT (merchant_ref) DO NOTHING`

	_, err := r.pool.Exec(ctx, q, merchantRef, creds.ClientID, creds.ClientSecret)
	return err
}
sql
CREATE TABLE merchant_credentials (
    merchant_ref   TEXT PRIMARY KEY,
    client_id      TEXT NOT NULL,
    client_secret  TEXT NOT NULL,
    external_id    UUID NOT NULL,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- In production, consider encrypting client_secret at rest (pgcrypto, KMS,
-- envelope encryption) or storing it in a vault and keeping only a reference here.

Why persist external_id

This UUID is the same value your validation URL returns on the health check of /app/client/generate. You will need it every time you render the merchant's checkout — it is the third parameter of AppmaxScripts.init(...) and the external-id header on CDN calls. Without persisting it, you lose access to the installation identifier. Full reference at external-id.

To run:

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

go
// 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.

go
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/generate returns Invalid token.
  • To make the handler idempotent, query your database by merchant_ref before trying the swap. If credentials exist, treat as success and redirect — that's what the example above does.

Error handling

SituationWhat to do
/app/client/generate returns Invalid tokenToken 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/generateDon't retry with the same token. Check your database for credentials — if none arrived, restart the flow.
Database persistence failureYou 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 log token, client_secret or 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_in is 1h, so many instances hammering /oauth2/token is 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