Skip to content

Webhooks

Overview

Appstore webhooks allow your application to receive real-time notifications about events that occur on the Appmax platform. When an event happens (order approved, customer created, subscription cancelled, etc.), Appmax sends a POST request to the URL you configured when creating the app.

The payload is sent as JSON with a standard envelope that includes event metadata and the resource-specific data. There are 4 event types (order, customer, payment, subscription) totaling 40 available events.

INFO

Appstore webhooks are dispatched in real time as soon as the event occurs on the platform.

Payload Structure (Envelope)

All webhooks share the same envelope. The data field varies depending on the event_type.

json
{
  "event": "order_approved",
  "event_type": "order",
  "site_id": "uuid-do-site",
  "app_id": "uuid-do-app",
  "client_key": "chave-externa",
  "external_key": "chave-externa",
  "data": { },
  "partner_merchant": {
    "merchant_email": "[email protected]",
    "merchant_document_number": "12345678000199",
    "merchant_phone": "11999999999"
  }
}
FieldTypeRequiredDescription
eventstringYesEvent identifier (e.g., order_approved)
event_typestringYesEvent type: order, customer, payment, or subscription
site_idstringYesUUID of the site where the event occurred
app_idstringYesUUID of the app receiving the webhook
client_keystring | nullNoKey configured by the merchant for external identification
external_keystring | nullNoExternal key associated with the resource
dataobjectYesResource data -- varies depending on the event_type
partner_merchantobjectYesMerchant data: merchant_email, merchant_document_number, merchant_phone

Event Table

Customer

Descriptioneventevent_type
Customer createdcustomer_createdcustomer
Customer interestedcustomer_interestedcustomer
Customer contactedcustomer_contactedcustomer

Order

Descriptioneventevent_type
Order authorizedorder_authorizedorder
Order approvedorder_approvedorder
Boleto createdorder_billet_createdorder
Order paidorder_paidorder
Order pending integrationorder_pending_integrationorder
Order refundedorder_refundorder
Partial refundorder_partial_refundorder
Upsell paidorder_up_soldorder
Pix generatedorder_pix_createdorder
Pix paidorder_paid_by_pixorder
Pix expiredorder_pix_expiredorder
Order integratedorder_integratedorder
Boleto overdueorder_billet_overdueorder
Order authorized with delayorder_authorized_with_delayorder
Chargeback in treatmentorder_chargeback_in_treatmentorder
Chargeback won (merchant favor)order_charge_back_gainorder
Refused by riskorder_refused_by_riskorder
Payment splitsplit_ordersorder

Payment

Descriptioneventevent_type
Payment authorized with delaypayment_authorized_with_delaypayment
Payment not authorizedpayment_not_authorizedpayment

Subscription

Descriptioneventevent_type
Subscription createdsubscription_createdsubscription
Subscription cancelledsubscription_cancelationsubscription
Subscription pausedsubscription_pausedsubscription
Subscription resumedsubscription_resumedsubscription
Recurring charge succeededsubscription_charge_successsubscription
Recurring charge failedsubscription_charge_failedsubscription
Product addedsubscription_product_addedsubscription
Product removedsubscription_product_removedsubscription
Product quantity changedsubscription_product_quantity_changedsubscription
Frequency changedsubscription_frequency_changedsubscription
Cycle skippedsubscription_cycle_skippedsubscription
Cycle unskippedsubscription_cycle_unskippedsubscription
Billing day changedsubscription_billing_day_changedsubscription
Next billing date changedsubscription_next_billing_day_changedsubscription
Address updatedsubscription_address_updatedsubscription
Payment method updatedsubscription_payment_method_updatedsubscription
Subscription delayed (legacy)subscription_delayedsubscription

INFO

The essential events for integration are order (order_*) and payment (payment_*) events.

WARNING

Every event depends on the matching permission granted to the app. An app without the subscription-product-added permission, for example, does not receive subscription_product_added -- even when the event happens in the store. Review the app permissions in the Appstore before investigating an event that "never arrives".

subscription_delayed is a legacy event, kept only for backward compatibility: the current subscription engine does not emit it. Use subscription_charge_failed to detect a failed recurring charge.

Payloads by Event Type

Order Events

The data field for order type events contains the following fields:

FieldTypeDescription
order_idintOrder ID
statusstringCurrent order status
totalintTotal amount in cents (12300 = R$ 123.00)
freight_valueintShipping amount in cents
merchant_totalintMerchant net amount in cents
merchant_affiliate_totalintMerchant affiliate amount in cents
discountintDiscount amount in cents
interestintInterest amount in cents
upsell_order_idint | nullAssociated upsell order ID
payment_link_idint | nullPayment link ID
paid_atstring | nullPayment date/time
integrated_atstring | nullIntegration date/time
refund_atstring | nullRefund date/time
created_atstringOrder creation date/time
productsarrayList of products in the order
payment_infoobjectPayment information (varies by method)
client_keystring | nullExternal identification key
external_keystring | nullExternal key
cashback_usedint | nullCashback used in cents
cashback_reservedint | nullCashback reserved in cents
cashback_statusstring | nullCashback status
notification_typestringNotification type

products[] fields:

FieldTypeDescription
skustringProduct SKU
namestringProduct name
priceintUnit price in cents
quantityintQuantity

payment_info fields (conditional by payment method):

For Pix:

FieldTypeDescription
pix.end_to_end_idstringPix transaction end-to-end ID
pix.pix_creation_datestringPix creation date
pix.pix_expiration_datestringPix expiration date
pix.pix_emvstringEMV code (copy and paste)
pix.pix_refstringPix reference
pix.pix_qrcodestringQR Code image URL
pix.pix_payment_linkstringPix payment link

For Boleto:

FieldTypeDescription
boleto.boleto_overdue_datestringDue date
boleto.boleto_urlstringBoleto URL
boleto.boleto_digitable_linestringDigitable line

For Credit Card / Apple Pay:

FieldTypeDescription
credit_card.installmentsintNumber of installments
credit_card.card_brandstringCard brand
credit_card.nsustringTransaction NSU
credit_card.authorization_codestringAuthorization code
credit_card.captured_atstringCapture date/time

Example: Order approved with credit card (order_approved)

json
{
  "event": "order_approved",
  "event_type": "order",
  "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "app_id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
  "client_key": "merchant-key-123",
  "external_key": "ext-order-456",
  "data": {
    "order_id": 3531,
    "status": "aprovado",
    "total": 25990,
    "freight_value": 1500,
    "merchant_total": 23400,
    "merchant_affiliate_total": 0,
    "discount": 0,
    "interest": 0,
    "upsell_order_id": null,
    "payment_link_id": null,
    "paid_at": "2025-03-15 14:30:00",
    "integrated_at": null,
    "refund_at": null,
    "created_at": "2025-03-15 14:28:00",
    "products": [
      {
        "sku": "PROD-001",
        "name": "Curso de Marketing Digital",
        "price": 25990,
        "quantity": 1
      }
    ],
    "payment_info": {
      "credit_card": {
        "installments": 3,
        "card_brand": "visa",
        "nsu": "0012345678",
        "authorization_code": "AUTH9876",
        "captured_at": "2025-03-15 14:30:00"
      }
    },
    "client_key": "merchant-key-123",
    "external_key": "ext-order-456",
    "cashback_used": null,
    "cashback_reserved": null,
    "cashback_status": null,
    "notification_type": "order_approved"
  },
  "partner_merchant": {
    "merchant_email": "[email protected]",
    "merchant_document_number": "12345678000199",
    "merchant_phone": "11999999999"
  }
}

Example: Pix paid (order_paid_by_pix)

json
{
  "event": "order_paid_by_pix",
  "event_type": "order",
  "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "app_id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
  "client_key": null,
  "external_key": null,
  "data": {
    "order_id": 4201,
    "status": "aprovado",
    "total": 9900,
    "freight_value": 0,
    "merchant_total": 8910,
    "merchant_affiliate_total": 0,
    "discount": 0,
    "interest": 0,
    "upsell_order_id": null,
    "payment_link_id": 789,
    "paid_at": "2025-03-15 15:10:00",
    "integrated_at": null,
    "refund_at": null,
    "created_at": "2025-03-15 15:05:00",
    "products": [
      {
        "sku": "EBOOK-042",
        "name": "E-book Receitas Fit",
        "price": 9900,
        "quantity": 1
      }
    ],
    "payment_info": {
      "pix": {
        "end_to_end_id": "E123456782025031515100001",
        "pix_creation_date": "2025-03-15 15:05:00",
        "pix_expiration_date": "2025-03-15 15:35:00",
        "pix_emv": "00020126580014br.gov.bcb.pix0136a1b2c3d4-e5f6-7890-abcd-ef12345678905204000053039865802BR5925APPMAX PAGAMENTOS LTDA6009SAO PAULO62070503***63041D3D",
        "pix_ref": "PIX-REF-4201",
        "pix_qrcode": "https://api.appmax.com.br/pix/qrcode/4201.png",
        "pix_payment_link": "https://pay.appmax.com.br/pix/4201"
      }
    },
    "client_key": null,
    "external_key": null,
    "cashback_used": null,
    "cashback_reserved": null,
    "cashback_status": null,
    "notification_type": "order_paid_by_pix"
  },
  "partner_merchant": {
    "merchant_email": "[email protected]",
    "merchant_document_number": "12345678000199",
    "merchant_phone": "11999999999"
  }
}

Example: Boleto created (order_billet_created)

json
{
  "event": "order_billet_created",
  "event_type": "order",
  "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "app_id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
  "client_key": "merchant-key-123",
  "external_key": null,
  "data": {
    "order_id": 4305,
    "status": "aguardando_pagamento",
    "total": 34900,
    "freight_value": 2000,
    "merchant_total": 31410,
    "merchant_affiliate_total": 0,
    "discount": 500,
    "interest": 0,
    "upsell_order_id": null,
    "payment_link_id": null,
    "paid_at": null,
    "integrated_at": null,
    "refund_at": null,
    "created_at": "2025-03-16 09:00:00",
    "products": [
      {
        "sku": "KIT-PREMIUM",
        "name": "Kit Premium de Suplementos",
        "price": 16700,
        "quantity": 2
      }
    ],
    "payment_info": {
      "boleto": {
        "boleto_overdue_date": "2025-03-19 23:59:59",
        "boleto_url": "https://api.appmax.com.br/boleto/4305.pdf",
        "boleto_digitable_line": "23793.38128 60000.000003 00000.000400 1 84340000034900"
      }
    },
    "client_key": "merchant-key-123",
    "external_key": null,
    "cashback_used": 500,
    "cashback_reserved": 1000,
    "cashback_status": "applied",
    "notification_type": "order_billet_created"
  },
  "partner_merchant": {
    "merchant_email": "[email protected]",
    "merchant_document_number": "98765432000188",
    "merchant_phone": "21988887777"
  }
}

Customer Events

The data field for customer type events contains the following fields:

FieldTypeDescription
customer_idintCustomer ID
customer_dataobjectCustomer personal data
customer_data.firstnamestringFirst name
customer_data.lastnamestringLast name
customer_data.emailstringEmail
customer_data.telephonestringPhone number
customer_data.document_numberstringCPF or CNPJ
customer_data.custom_txtstring | nullCustom field
customer_addressobjectCustomer address
customer_address.postcodestringPostal code (CEP)
customer_address.streetstringStreet name
customer_address.street_numberstringStreet number
customer_address.street_complementstring | nullAddress complement
customer_address.street_districtstringNeighborhood
customer_address.citystringCity
customer_address.statestringState (UF)
created_atstringCreation date/time
updated_atstringLast update date/time
client_keystring | nullExternal identification key
external_keystring | nullExternal key

Example: Customer created (customer_created)

json
{
  "event": "customer_created",
  "event_type": "customer",
  "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "app_id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
  "client_key": "merchant-key-123",
  "external_key": null,
  "data": {
    "customer_id": 2023,
    "customer_data": {
      "firstname": "Junior",
      "lastname": "Almeida",
      "email": "[email protected]",
      "telephone": "51983655100",
      "document_number": "12345678900",
      "custom_txt": null
    },
    "customer_address": {
      "postcode": "90010-000",
      "street": "Rua dos Andradas",
      "street_number": "1234",
      "street_complement": "Sala 501",
      "street_district": "Centro Histórico",
      "city": "Porto Alegre",
      "state": "RS"
    },
    "created_at": "2025-03-15 14:25:00",
    "updated_at": "2025-03-15 14:25:00",
    "client_key": "merchant-key-123",
    "external_key": null
  },
  "partner_merchant": {
    "merchant_email": "[email protected]",
    "merchant_document_number": "12345678000199",
    "merchant_phone": "11999999999"
  }
}

Payment Events

The data field for payment type events contains the following fields:

FieldTypeDescription
customer_idintCustomer ID
order_idintOrder ID
payment_typestringPayment method (e.g., credit_card, pix, boleto)
payment_totalintPayment amount in cents
cashback_usedint | nullCashback used in cents

Example: Payment not authorized (payment_not_authorized)

json
{
  "event": "payment_not_authorized",
  "event_type": "payment",
  "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "app_id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
  "client_key": null,
  "external_key": null,
  "data": {
    "customer_id": 2023,
    "order_id": 3532,
    "payment_type": "credit_card",
    "payment_total": 15900,
    "cashback_used": null
  },
  "partner_merchant": {
    "merchant_email": "[email protected]",
    "merchant_document_number": "12345678000199",
    "merchant_phone": "11999999999"
  }
}

Subscription Events

Subscription events are produced by the Appmax recurring-billing engine and delivered to your app with the same envelope as every other event.

The data field of every subscription event is the union of two blocks:

  1. Base fields -- present in all subscription events (null when they do not apply to that event).
  2. Event-specific fields -- vary by event, described in the table further below.

Base fields

FieldTypeDescription
subscription_idintSubscription ID. The stable identifier across the whole lifecycle
order_idintID of the order that originated the subscription (on creation) or of the charge that fired it
customer_idintID of the customer who owns the subscription
totalintOrder total in cents (12300 = R$ 123.00)
intervalstring | nullRecurrence unit: week, month or year
interval_countint | nullNumber of units between charges (interval=month + interval_count=2 = every 2 months)
statusstring | nullSubscription state at the moment of the event (see table below)
cashback_usedfloat | nullCashback applied to the order, when any
client_keystring | nullMerchant external key (repeated from the envelope)
external_keystring | nullSame value as client_key

INFO

interval, interval_count and status are only filled in on the events where they make sense. On change events (product, cycle, billing day) they arrive as null -- the subscription itself did not change state.

Event-specific fields

eventAdditional fields in data
subscription_createdstatus = active, interval, interval_count, next_charge_at (ISO-8601)
subscription_cancelationstatus = canceled
subscription_pausedstatus = paused
subscription_resumedstatus, next_billing_date
subscription_charge_successstatus = success, uuid (subscription UUID)
subscription_charge_failedstatus = failed, uuid (subscription UUID)
subscription_product_addedproducts -- full product list after the change
subscription_product_removedproducts -- full product list after the change
subscription_product_quantity_changedproducts -- full product list after the change
subscription_frequency_changedfrequency (week/month/year), interval_count, next_charge_at (ISO-8601)
subscription_cycle_skippednext_billing_day (ISO-8601)
subscription_cycle_unskippednext_billing_day (ISO-8601)
subscription_billing_day_changedbilling_day
subscription_next_billing_day_changednext_billing_day (ISO-8601)
subscription_payment_method_updatedpayment_method (credit_card or pix)
subscription_address_updatedFields of the updated shipping address

WARNING

Product events (subscription_product_added, subscription_product_removed, subscription_product_quantity_changed) always send the full consolidated list of products after the change -- not just the item that changed. Replace your local copy with the list you receive instead of applying a delta.

Each products item has this shape:

json
{
  "name": "Produto A - Mensal",
  "price": 100.0,
  "quantity": 1,
  "variant_id": "va-month"
}

Example: Subscription created (subscription_created)

json
{
  "event": "subscription_created",
  "event_type": "subscription",
  "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "app_id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
  "client_key": "merchant-key-123",
  "external_key": "merchant-key-123",
  "data": {
    "subscription_id": 501,
    "order_id": 3532,
    "customer_id": 2023,
    "total": 4990,
    "interval": "month",
    "interval_count": 1,
    "status": "active",
    "next_charge_at": "2025-04-15T14:30:00-03:00",
    "cashback_used": null,
    "client_key": "merchant-key-123",
    "external_key": "merchant-key-123"
  },
  "partner_merchant": {
    "merchant_email": "[email protected]",
    "merchant_document_number": "12345678000199",
    "merchant_phone": "11999999999"
  }
}

Example: Recurring charge succeeded (subscription_charge_success)

json
{
  "event": "subscription_charge_success",
  "event_type": "subscription",
  "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "app_id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
  "client_key": "merchant-key-123",
  "external_key": "merchant-key-123",
  "data": {
    "subscription_id": 501,
    "order_id": 3987,
    "customer_id": 2023,
    "total": 4990,
    "interval": null,
    "interval_count": null,
    "status": "success",
    "uuid": "6f1e9a5c-8d24-4a1b-9f30-2c7b5e0a1d44",
    "cashback_used": null,
    "client_key": "merchant-key-123",
    "external_key": "merchant-key-123"
  },
  "partner_merchant": {
    "merchant_email": "[email protected]",
    "merchant_document_number": "12345678000199",
    "merchant_phone": "11999999999"
  }
}

INFO

Every billing cycle creates a new order. order_id changes on each charge; subscription_id stays the same. Use subscription_id to tie charges back to a subscription and order_id to reconcile with the order events (order_*) of that charge.

Example: Product added (subscription_product_added)

json
{
  "event": "subscription_product_added",
  "event_type": "subscription",
  "site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "app_id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
  "client_key": "merchant-key-123",
  "external_key": "merchant-key-123",
  "data": {
    "subscription_id": 501,
    "order_id": 3532,
    "customer_id": 2023,
    "total": 4990,
    "interval": null,
    "interval_count": null,
    "status": null,
    "products": [
      { "name": "Produto A - Mensal", "price": 100.0, "quantity": 1, "variant_id": "va-month" },
      { "name": "Produto B - Mensal", "price": 100.0, "quantity": 1, "variant_id": "vb-month" }
    ],
    "cashback_used": null,
    "client_key": "merchant-key-123",
    "external_key": "merchant-key-123"
  },
  "partner_merchant": {
    "merchant_email": "[email protected]",
    "merchant_document_number": "12345678000199",
    "merchant_phone": "11999999999"
  }
}

Idempotency for subscription events

The subscription envelope does not carry a unique event identifier. To deduplicate, use the combination event + subscription_id + order_id.

Careful: charge events legitimately repeat on every cycle -- the pair subscription_charge_success + subscription_id is not unique over time. It is order_id, new on every charge, that tells one cycle from another. Change events (product, frequency, cycle), on the other hand, can repeat the same order_id; for those, also factor in the payload content or the reception timestamp.

Event Temporal Flow

The diagrams below illustrate the typical sequence of events for each payment method.

Credit Card:

customer_created -> order_authorized -> order_approved -> order_paid -> order_integrated

Pix:

customer_created -> order_pix_created -> [timeout: order_pix_expired]
                                      -> order_paid_by_pix -> order_approved -> order_integrated

Boleto:

customer_created -> order_billet_created -> [overdue: order_billet_overdue]
                                         -> order_paid -> order_approved -> order_integrated

Refund / Chargeback:

[approved order] -> order_refund (full)
                 -> order_partial_refund (partial)
                 -> order_chargeback_in_treatment -> order_charge_back_gain

Subscription -- lifecycle:

subscription_created -> subscription_charge_success   (every cycle, a new order_id)
                     -> subscription_charge_failed    (charge declined)
                     -> subscription_cancelation      (end of the subscription)

subscription_paused <-> subscription_resumed

Subscription -- changes (any time while the subscription is active):

products    -> subscription_product_added / subscription_product_removed
               subscription_product_quantity_changed
recurrence  -> subscription_frequency_changed
               subscription_billing_day_changed / subscription_next_billing_day_changed
               subscription_cycle_skipped <-> subscription_cycle_unskipped
account     -> subscription_payment_method_updated / subscription_address_updated

INFO

A recurring charge also fires the order events (order_*) of the order created for that cycle. If your app subscribes to both types, expect to receive subscription_charge_success and order_approved for the same order_id.

WARNING

Event ordering is not guaranteed. Network delays, retries, and asynchronous processing can alter the sequence. Always check the current state of the resource before making decisions based on events.

Retry Policy

When the endpoint fails to receive a webhook, Appmax initiates a retry cycle:

Attempt 1 (original) -- after event delay
        | failure
Attempt 2 -- +30 minutes
        | failure
Attempt 3 -- +2 hours
        | failure
Attempt 4 -- +4 hours
        | failure
Webhook discarded (no notification)
  • HTTP Timeout: 5 seconds
  • Success codes: 200, 201, 202, 203, 204, 205, 206, 207, 208, 226
  • Failure: any other HTTP code or timeout triggers a retry
  • Maximum: 4 attempts (1 original + 3 retries)

DANGER

After 4 unsuccessful attempts, the webhook is permanently discarded. There is no notification to the developer. Monitor your endpoint actively.

HTTP Headers

Every webhook request is sent with the following headers:

HeaderValue
Content-Typeapplication/json
User-AgentGuzzleHttp/7

WARNING

Appmax does not send a signature header (HMAC) or authentication token in webhooks. We recommend validating the origin through other means (see Best Practices).

How to Receive Webhooks

Your endpoint must:

  1. Accept POST requests with Content-Type: application/json
  2. Respond with HTTP 200 within 5 seconds
  3. Process the event asynchronously (do not block the response)

WARNING

If the endpoint does not respond 200 within 5 seconds, Appmax initiates the retry cycle. Process the event in the background and respond immediately.

Code Examples

go
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"sync"

	"github.com/gin-gonic/gin"
)

var (
	processed sync.Map
)

func main() {
	r := gin.Default()

	r.POST("/webhooks/appmax", func(c *gin.Context) {
		var payload struct {
			Event     string          `json:"event"`
			EventType string          `json:"event_type"`
			Data      json.RawMessage `json:"data"`
		}

		if err := c.ShouldBindJSON(&payload); err != nil {
			c.JSON(200, gin.H{"received": true}) // respond 200 even on error
			return
		}

		// Respond 200 immediately to avoid the 5s timeout
		c.JSON(200, gin.H{"received": true})

		// Extract ID for idempotency
		var data struct {
			OrderID    int `json:"order_id"`
			CustomerID int `json:"customer_id"`
		}
		json.Unmarshal(payload.Data, &data)

		id := data.OrderID
		if id == 0 {
			id = data.CustomerID
		}
		key := fmt.Sprintf("%d-%s", id, payload.Event)

		if _, loaded := processed.LoadOrStore(key, true); loaded {
			return // duplicate
		}

		// Process in a goroutine -- in production, send to a queue
		go func() {
			log.Printf("Processing: %s (%s)", payload.Event, payload.EventType)
			// Your logic here
		}()
	})

	r.Run(":3000")
}
javascript
const express = require('express');
const app = express();
app.use(express.json());

// Map to track already processed events (in production, use a database)
const processed = new Set();

app.post('/webhooks/appmax', (req, res) => {
  // Respond 200 immediately to avoid the 5s timeout
  res.status(200).json({ received: true });

  const { event, event_type, data } = req.body;
  const idempotencyKey = `${data.order_id || data.customer_id}-${event}`;

  if (processed.has(idempotencyKey)) {
    console.log(`Duplicate event ignored: ${idempotencyKey}`);
    return;
  }

  processed.add(idempotencyKey);
  console.log(`Processing: ${event} (${event_type})`);

  // Process event asynchronously
  // In production, send to a queue (Bull, RabbitMQ, etc.)
});

app.listen(3000, () => console.log('Webhook listener on port 3000'));
python
from flask import Flask, request, jsonify
import threading

app = Flask(__name__)
processed = set()

@app.route('/webhooks/appmax', methods=['POST'])
def webhook():
    payload = request.get_json()
    event = payload.get('event')
    data = payload.get('data', {})

    key = f"{data.get('order_id') or data.get('customer_id')}-{event}"

    if key in processed:
        return jsonify(received=True), 200

    processed.add(key)

    # Process in background to respond quickly
    threading.Thread(target=process_event, args=(payload,)).start()

    return jsonify(received=True), 200

def process_event(payload):
    print(f"Processing: {payload['event']}")
    # Your logic here

if __name__ == '__main__':
    app.run(port=3000)
php
// routes/api.php
Route::post('/webhooks/appmax', [WebhookController::class, 'handle']);

// app/Http/Controllers/WebhookController.php
class WebhookController extends Controller
{
    public function handle(Request $request)
    {
        $payload = $request->all();

        // Dispatch to async job and respond 200 immediately
        ProcessWebhook::dispatch($payload);

        return response()->json(['received' => true]);
    }
}

// app/Jobs/ProcessWebhook.php
class ProcessWebhook implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(private array $payload) {}

    public function handle()
    {
        $event = $this->payload['event'];
        $data = $this->payload['data'];
        $key = ($data['order_id'] ?? $data['customer_id']) . '-' . $event;

        // Check idempotency
        if (Cache::has("webhook:{$key}")) {
            return;
        }
        Cache::put("webhook:{$key}", true, now()->addHours(24));

        // Process event
        Log::info("Webhook received: {$event}", $this->payload);
    }
}

Best Practices

  1. Respond 200 before processing. The timeout is 5 seconds. Synchronous processing causes unnecessary retries. Respond immediately and process in the background (queue, thread, async job).

  2. Implement idempotency. Use order_id + event (or customer_id + event; for subscriptions, subscription_id + order_id + event) as a unique key. Retries legitimately resend the same event, and your system needs to handle duplicates without side effects.

  3. Store the raw payload. Save the complete JSON to a database or log before processing. This makes debugging easier and allows manual reprocessing without depending on resending.

  4. Do not rely on event ordering. Network delays and retries can alter the sequence. Always check the current state of the resource (via API, if needed) before making decisions based on an event.

  5. Use HTTPS. Protect data in transit. Appmax sends webhooks to both HTTP and HTTPS URLs, but customer and payment data are included in the payload.

  6. Handle duplicates. Retries legitimately resend the same event. Ensure that processing the same event twice does not cause side effects (double charging, sending duplicate emails, etc.).

  7. Validate the origin. Since there is no HMAC header, consider filtering by source IP, validating the payload structure against the expected schema, or confirming the event via the Appmax API.

Webhooks: Appstore vs Dashboard

There are two types of webhooks in the Appmax platform. Don't confuse them:

AspectAppstore WebhooksDashboard Webhooks
Configured byApp developer, when creating the appMerchant, in the store's admin panel
ScopeAll merchants who install the appOnly that specific merchant's store
Destination URLApp host URL (set in the Appstore)URL defined by the merchant in the panel
Events40 events documented on this pageSubset of events (varies by configuration)
Payload credentialsapp_id, site_id, external_keyDifferent format, no app_id
When to useAppstore integrations (this guide)Direct merchant integrations

WARNING

If you're integrating via the Appstore (you created an app, merchants install it), use the webhooks documented on this page. Dashboard webhooks are for merchants who configure notifications directly, without an intermediary app.

Errors and Troubleshooting

ScenarioWhat happensHow to resolve
Endpoint returns non-2xx HTTP statusRetry initiated (up to 4 attempts)Return 200, 201, or 202
Endpoint does not respond within 5sTimeout, retry initiatedProcess async and respond 200 immediately
Endpoint returns 502Invalid URL or server is downVerify the registered URL and server availability
Endpoint returns 401/403Authentication failure, retry initiatedRemove authentication from the endpoint or add a whitelist
Retries exhausted (4 attempts)Webhook permanently discardedMonitor your endpoint actively and request resending from support
Webhook takes long to arriveEvent may be in the retry queueCheck if the endpoint responded 200 on previous attempts
Webhook does not arrive (Yampi order)Webhook suppressedIntentional behavior for orders originating from Yampi
customer_interested does not fireCustomer already has an orderEvent only fires for leads with no associated order

Testing and Debugging

webhook.site

A free service to inspect received webhooks. Create a temporary URL at webhook.site, configure it as the app's webhook URL, and view the received payloads in real time.

ngrok

To test webhooks directly in your local environment:

bash
ngrok http 3000

Use the HTTPS URL generated by ngrok as the app's webhook URL. Requests will be forwarded to localhost:3000, enabling end-to-end debugging with breakpoints.

General tips

  • Check your server logs to confirm that requests are arriving
  • Inspect the request headers to confirm Content-Type: application/json
  • Validate that the received JSON is well-formed before processing
  • Confirm that the app has the necessary permissions to receive the desired events