Webhooks
Migrando de um evento legado? Veja o guia de migração.
Webhooks are how GalaxyPay pushes status updates to your backend in real time. Use them for fulfillment — never rely on polling alone. If a delivery is missed, use the pull/reconciliation API to catch up.
#0. Start here (most common questions)
#One URL or one URL per event?
One URL. In the dashboard (Integrations → Webhooks → New endpoint) you register one HTTPS endpoint and check multiple events. GalaxyPay sends a separate POST for each occurrence, always to the same URL.
| Some other PSPs | GalaxyPay |
|---|---|
/webhook/cashin, /webhook/cashout, /webhook/refund (path per type) | https://your-shop.com/hooks/galaxionpay + event list at registration |
| One HTTP route per event | One route, many events; the type field (or legacy event) distinguishes them |
Recommended generic URL:
https://your-domain.com/hooks/galaxionpayAvoid paths like /api/charge/created unless your router requires it — the path does not select the event; the dashboard checkboxes (or the events array in the API) do.
#What does “all events on one URL” look like?
You do not receive one payload with every event type. You receive one POST per status change. Branch on the type:
const type = body.type || body.event; // canonical uses type; legacy uses event
switch (type) {
case 'charge.paid':
case 'payment.completed': // legacy
// fulfill order
break;
case 'charge.failed':
case 'charge.expired':
// cancel / expire
break;
case 'charge.refunded':
case 'payment.refunded': // legacy alias — same refund
// handle refund
break;
default:
// unknown type: 200 OK and ignore
}
res.sendStatus(200); // respond 2xx quickly#Refunds — which event?
| Need | Event | Family |
|---|---|---|
| Charge paid | charge.paid | canonical |
| Charge failed / expired | charge.failed / charge.expired | canonical |
| Charge refund | charge.refunded (legacy alias payment.refunded) | canonical |
| Payout settled / failed | payout.paid / payout.failed | canonical (not a refund) |
Subscribe to charge.refunded on the same endpoint if you need refunds — it fires for every refund path (merchant-initiated via the API, BACEN MED, and the automatic payer-CPF devolução). payment.refunded is the legacy alias for the same event and is still delivered to endpoints subscribed to it. Do not confuse with payout.* (wallet withdrawal).
The refund object carries the refunded amount and its origin:
{
"id": "evt_…",
"object": "event",
"type": "charge.refunded",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "ch_01JABCDEF",
"object": "charge",
"amount": 1000,
"currency": "BRL",
"status": "refunded",
"payment_method": "pix",
"amount_refunded": 1000,
"settlement": { "end_to_end_id": "E0000…" },
"refund": {
"amount": 1000,
"currency": "BRL",
"origin": "MERCHANT",
"reason": "customer request",
"end_to_end_id": "E0000…"
}
}
}
}refund.origin is MERCHANT for a merchant/admin-initiated refund (BACEN MED flows through the same path) or AUTOMATIC_PAYER_RESTRICTION for the automatic Pix devolução triggered when a settled payer's CPF/CNPJ did not match the charge's expected payer. amount_refunded is the total refunded so far (equal to refund.amount for a single refund; larger across partial refunds). The legacy payment.refunded delivery carries the same facts flat in data (refundAmount, currency, origin, reason, endToEndId).
#Several accounts / shops on the same URL
Fine. Each GalaxyPay account has its own keys and endpoints, but your server URL can be shared. Segment using the payload (data.object.id = ch_…, metadata you set when creating the charge, etc.).
#Dashboard path (no API)
- Integrations → Webhooks → New endpoint
- Generic HTTPS URL (e.g.
…/hooks/galaxionpay) - Check at least:
charge.created,charge.paid,charge.failed,charge.expired(+charge.refundedif you need refunds) - Save the secret (shown once)
- Click Test and confirm your server returned 2xx
#1. The canonical event envelope
New integrations subscribe to the canonical events (charge.*, payout.*). Every delivery is an HTTPS POST to your registered URL with this shape:
{
"id": "evt_5f8a3c1e9b2d4a6f8e0c1b3d5f7a9c1e",
"object": "event",
"api_version": "2026-07-23",
"type": "charge.paid",
"created_at": "2026-07-23T14:31:00.000Z",
"data": {
"object": {
"id": "ch_01JABCDEF",
"object": "charge",
"amount": 1000,
"currency": "BRL",
"status": "paid",
"payment_method": "pix",
"settlement": { "end_to_end_id": "E00000000202607231431abcdef1234" }
}
}
}id(evt_…) is the business-event id — stable across every delivery attempt and every endpoint fan-out. Deduplicate on it.data.objectis the same publiccharge/payoutshape the REST API returns (serialized through the same allowlist asGET /v1/charges/:id— no provider name, cost, secret, or raw PSP payload can appear here).- Always handle unknown
typevalues gracefully (200 OK+ ignore) so adding new event types never breaks you.
#Event catalog
GET /v1/webhooks/event-catalog returns the live, authoritative list (no auth required):
curl https://galaxionpay.com/v1/webhooks/event-catalog| Event | When it fires |
|---|---|
charge.created | Cobrança criada (Pix gerado, aguardando pagamento). |
charge.paid | Cobrança paga e confirmada. |
charge.failed | Cobrança falhou ou foi cancelada. |
charge.expired | Cobrança expirou sem pagamento. |
charge.refunded | Cobrança reembolsada (total ou parcial) — reembolso do lojista, MED ou devolução automática (trava de CPF). |
payout.created | Saque solicitado. |
payout.paid | Saque liquidado com sucesso. |
payout.failed | Saque falhou ou foi rejeitado. |
payment.refunded | Legacy alias for charge.refunded — still delivered to endpoints subscribed to it. |
Subscribe to the canonical names in events when you register your endpoint. For refunds, include charge.refunded.
#One occurrence, one delivery — even with two names
Several events have a canonical name and a legacy alias (charge.paid / payment.completed, payout.created / withdrawal.created, and so on). They are the same occurrence, not two.
Each endpoint gets one delivery. If an endpoint subscribes to both names of the same pair, the delivery goes out under the canonical name — the legacy alias is not sent separately. Subscribing to both duplicates nothing and is not an error, but it buys you nothing either: subscribe to the canonical name only.
An endpoint subscribed to the legacy alias alone keeps receiving it, with the historical {event,data} body and the sha256=<hex> signature, until the announced sunset.
Disputes (MED) do not fire a
charge.*webhook — the charge was paid, and a MED is not a charge failure. Detect a dispute via the Disputes panel, the email, or by re-reading the charge status (disputed). See Disputes and MED.
A late
charge.paidcan follow acharge.expired. If the acquirer confirms a payment only after the charge already expired (delayed webhook / lagging status API), GalaxyPay opens a manual verification case and, once an operator settles it, deliverscharge.paidfor that same charge. The latercharge.paidis the final state — treat it as paid, even though you had receivedcharge.expiredbefore. Never rely oncharge.expiredbeing terminal.
#2. Register your endpoint
Endpoint POST /v1/webhooks/endpoints
Headers
apikey: gx_live_...
Content-Type: application/jsonBody
{
"url": "https://merchant.example.com/hooks/galaxionpay",
"events": ["charge.paid", "charge.failed", "charge.expired"]
}Response 201 Created
{
"id": "e5f6a7b8-c9d0-4123-9ef0-123456789012",
"url": "https://merchant.example.com/hooks/galaxionpay",
"events": ["charge.expired", "charge.failed", "charge.paid"],
"secret": "b8f3a9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
"status": "ACTIVE"
}Save the
secretimmediately. It is shown only at creation time and is required to verify every incoming webhook. We never display it again.
#Legacy events (still supported)
Existing endpoints subscribed to the legacy family keep working, unchanged, with no cutoff date announced yet:
| Legacy event | Canonical replacement |
|---|---|
payment.created | charge.created |
payment.completed | charge.paid |
payment.failed | charge.failed |
payment.expired | charge.expired |
payment.refunded | charge.refunded |
withdrawal.created | payout.created |
withdrawal.completed | payout.paid |
withdrawal.failed | payout.failed |
One name per event. An endpoint subscribed to both the legacy and the canonical name of the same event (for example
payment.completedandcharge.paid) receives it once, under the legacy name. Subscribe to one of the two — new integrations should use the canonical name. The dashboard does not let you select both.
Legacy topic aliases —
payment.status_changedexpands to["payment.completed", "payment.failed"];withdrawal.status_changedexpands to["withdrawal.completed", "withdrawal.failed"]. Both are still accepted at registration time.
A legacy-subscribed endpoint keeps receiving the historical wire format for the exact same business event — the underlying occurrence is the same, only the envelope and event name differ per subscription:
{
"event": "payment.completed",
"data": {
"transactionId": "a1b2c3d4-e5f6-4789-9abc-def012345678",
"amount": 24900,
"status": "PAID",
"previousStatus": "WAITING_PAYMENT",
"paidWith": "PIX",
"platformFee": 200,
"occurredAt": "2026-07-23T14:31:00.000Z"
}
}providerFee/netAmount are deliberately never forwarded — they would let you back-calculate GalaxyPay's own PSP cost.
#Account/KYC events (legacy envelope only)
kyc.submitted, kyc.approved, kyc.rejected, and a pair of account-lifecycle events also fire and always use the legacy { event, data } envelope (they are not charge.*/payout.*, so they never get the canonical evt_ wrapper or the t=,v1= signature).
⚠️ Known naming gap: the account-lifecycle events currently emit an internal field name verbatim in their payload — a tracked glossary violation, not an intentional part of the public contract. See the migration guide's known gap and followups for the exact event and field names. Do not build a permanent integration against that field name.
#3. Verify the signature
Every delivery is signed with HMAC-SHA256 over the raw request body using your endpoint's secret. There are two schemes, chosen automatically by event family — your verifier should support both if you have any legacy subscription active.
| Header | Scheme | Applies to |
|---|---|---|
X-GalaxyPay-Signature | t=<unix-seconds>,v1=<hex> — signed payload is "<t>.<rawBody>" | Canonical events (charge.*, payout.*) |
X-GalaxyPay-Signature | sha256=<hex> — signed payload is the raw body alone | Legacy events (payment.*, withdrawal.*, kyc.*, and the account-lifecycle events above) |
X-GalaxyPay-Delivery-Id | Opaque id, stable across every retry of the same delivery | Both |
X-GalaxyPay-Event-Type | Mirrors the delivered event name | Both |
The canonical scheme embeds a timestamp in the signed material specifically so you can reject replays outside a tolerance window (recommended: 5 minutes) — the legacy scheme has no timestamp and cannot do this.
#Node.js (Express) — canonical scheme
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.GALAXIONPAY_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verifyCanonical(rawBody, header, secret) {
const [tPart, v1Part] = header.split(',');
const t = Number(tPart?.split('=')[1]);
const v1 = v1Part?.split('=')[1];
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
'/hooks/galaxionpay',
// capture raw body — express.json() strips it
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('X-GalaxyPay-Signature') || '';
if (!verifyCanonical(req.body.toString('utf8'), signature, SECRET)) {
return res.status(401).send('invalid signature');
}
const { id: eventId, type, data } = JSON.parse(req.body.toString('utf8'));
// 200 OK FAST. Do work in a background queue.
res.status(200).end();
queue.enqueue({ eventId, type, data, deliveryId: req.header('X-GalaxyPay-Delivery-Id') });
},
);Prefer not to hand-roll this?
@galaxionpay/node'sgalaxionpay.webhooks.verify(rawBody, signature, secret)/.parse(...)handle both schemes (canonical and legacy) for you — seepackages/sdk-node/README.md.
#PHP — legacy scheme (existing payment.*/withdrawal.* subscriptions)
$secret = getenv('GALAXIONPAY_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_GALAXYPAY_SIGNATURE'] ?? '';
$expect = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expect, $sig)) {
http_response_code(401);
exit('invalid signature');
}
$body = json_decode($raw, true);
http_response_code(200);
// queue $body for processing#Python (Flask) — legacy scheme
import hmac, hashlib, os
from flask import request, abort
SECRET = os.environ['GALAXIONPAY_WEBHOOK_SECRET'].encode()
@app.post('/hooks/galaxionpay')
def galaxionpay_hook():
raw = request.get_data() # bytes, untouched
sig = request.headers.get('X-GalaxyPay-Signature', '')
expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
payload = request.get_json()
# respond 200 fast, process async
return '', 200#4. Delivery guarantees, retries and DLQ
- Success Any
2xxresponse confirms the delivery and stops retries. - Timeout 30 seconds. A slower response counts as a failure.
- Retry schedule Exponential backoff (
1s × 2^attempt), capped at 6 hours between attempts (WEBHOOK_BACKOFF_CAP_MS, default21_600_000). - Maximum attempts 15 (
WEBHOOK_MAX_ATTEMPTS). After the last failed attempt the delivery moves toCANCELLED(dead-lettered) — it is not deleted, and can be replayed manually. - Changing your endpoint Failing deliveries follow the endpoint you have registered now. Add an endpoint (or re-activate one) and the deliveries that are failing on your other endpoints move to it. Deactivate or delete an endpoint, or stop subscribing to an event, and whatever was still queued for it moves to your other active endpoints subscribed to the same event — or is cancelled if there is none. Changing an endpoint's
urlretries its failing deliveries right away, with a fresh attempt budget. A moved delivery gets a newX-GalaxyPay-Delivery-Id; the canonical event'sid(evt_…) stays the same. - 429 handling If your endpoint returns
429with aRetry-Afterheader (seconds) or a JSON body{ "retry_after": <seconds> }, that value is honored (capped at 5 minutes) instead of the blind exponential backoff. - Delivery id stability
X-GalaxyPay-Delivery-Idis identical across every attempt of the same delivery — use it as your primary dedup key.
⚠️ A delivery may arrive more than once. Always make your handler idempotent by deduplicating on
X-GalaxyPay-Delivery-Id(or the canonical event'sid/evt_…, which stays the same when a delivery moves to another endpoint).
#Idempotent handler pattern
async function handle({ deliveryId, eventId, type, data }) {
// Atomic insert — fails if we've seen this delivery before
const inserted = await db.processedWebhooks.insertIgnore({
id: deliveryId ?? eventId,
receivedAt: new Date(),
});
if (!inserted) return; // already handled
if (type === 'charge.paid') {
await orders.markPaid(data.object.id, data.object);
}
}#5. Pull / reconciliation API
If a push was missed (your receiver was down for hours), pull the events you missed instead of losing them.
curl "https://galaxionpay.com/v1/webhooks/events?since=2026-07-23T00:00:00Z&limit=50" \
-H "apikey: $GALAXIONPAY_API_KEY"{
"data": [
{ "eventType": "charge.paid", "payload": { "...": "..." }, "status": "DELIVERED", "lastStatusCode": 200, "createdAt": "2026-07-23T14:31:00.000Z" }
],
"nextCursor": "MjAyNi0wNy0yM1QxNDozMTowMC4wMDBafGRlbF8xMjM="
}- Cursor pagination on
(createdAt desc, id desc)— passnextCursorback ascursorfor the next page; treat it as an opaque token. - Filters:
since,until(ISO 8601),status,eventType. - Scoped strictly to your own endpoints — never returns platform-internal deliveries.
#6. Testing an endpoint
POST /v1/webhooks/endpoints/:id/test sends a synchronous, non-persisted sample delivery so you can check status, latency, and signature handling.
curl -X POST "https://galaxionpay.com/v1/webhooks/endpoints/<ENDPOINT_ID>/test" \
-H "apikey: $GALAXIONPAY_API_KEY"The test delivery currently always sends the legacy
payment.completedsample (sha256=signature) regardless of which events the endpoint is subscribed to — it exercises connectivity and signature handling, not the canonical envelope specifically.
#7. Production checklist
- Endpoint is HTTPS with a valid TLS certificate.
- Signature is verified on the raw body, before JSON parsing.
- Comparison uses constant-time (e.g.
timingSafeEqual/hash_equals/hmac.compare_digest). - Your verifier supports both signature schemes if any endpoint is still subscribed to a legacy event.
- Handler responds
2xxin under 5 seconds. Heavy work goes to a queue. - Idempotency on
X-GalaxyPay-Delivery-Id(orevt_…for canonical events). - Unknown
type/eventnames are ignored gracefully (200 OK, no error). - Secret is loaded from a secret manager — never committed.
- An alert fires if no webhook is received within an expected window; use the pull API as a backstop.
#8. Operations
#List recent deliveries
curl "https://galaxionpay.com/v1/webhooks/deliveries?limit=25" \
-H "apikey: $GALAXIONPAY_API_KEY"Each entry includes the attempt count, last status code, last response body, and the next retry timestamp.
#Replay a failed or cancelled delivery
curl -X POST "https://galaxionpay.com/v1/webhooks/deliveries/<DELIVERY_ID>/replay" \
-H "apikey: $GALAXIONPAY_API_KEY"Resets the attempt counter and requeues immediately. Bulk replay is available at POST /v1/webhooks/deliveries/replay-bulk with an optional { status, endpointId, limit } filter (default limit 50, max 500).
#Rotate a secret
curl -X POST "https://galaxionpay.com/v1/webhooks/endpoints/<ENDPOINT_ID>/rotate-secret" \
-H "apikey: $GALAXIONPAY_API_KEY"The new secret is returned once.
Rotation is an instantaneous server-side swap — every webhook GalaxyPay signs after a successful rotate-secret call uses the new secret. There is no server-side overlap window.
To rotate without dropped events, your verifier must temporarily accept both the old and new secret during your deploy:
// Try new first, fall back to old. Drop OLD_SECRET after the deploy soaks.
const ok = verify(req, NEW_SECRET) || verify(req, OLD_SECRET);
if (!ok) return res.status(401).end();Order of operations:
- Call rotate-secret → store the new secret alongside the old.
- Deploy your verifier with both secrets active.
- Soak for at least one minute (any in-flight retries clear).
- Remove the old secret on the next deploy.
#Update or delete an endpoint
curl -X PATCH "https://galaxionpay.com/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "apikey: $GALAXIONPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://merchant.example.com/hooks/galaxionpay-v2", "status": "ACTIVE"}'
curl -X DELETE "https://galaxionpay.com/v1/webhooks/endpoints/<ENDPOINT_ID>" \
-H "apikey: $GALAXIONPAY_API_KEY"PATCH updates only the fields you send (url, events, status: ACTIVE/INACTIVE) and never returns the secret. DELETE deactivates the endpoint for good: it leaves your list and receives nothing else, while its delivery history is kept. Deliveries still queued for it move to your other active endpoints subscribed to the same event — see Delivery guarantees.
#Stats
curl "https://galaxionpay.com/v1/webhooks/stats" \
-H "apikey: $GALAXIONPAY_API_KEY"Returns delivery counts by state (PENDING, PROCESSING, DELIVERED, FAILED, CANCELLED) plus total.
#FAQ
Q: Can I have multiple endpoints? A: Yes. Register as many as you want — useful for separating staging, production, and observability sinks.
Q: Can an endpoint mix canonical and legacy events?
A: Yes. events on a single endpoint can include both families (e.g. ["charge.paid", "withdrawal.failed"]); each delivered event uses the envelope/signature scheme that matches its own name.
Q: Will payment.*/withdrawal.* events stop working?
A: Not yet, and no cutoff date has been announced. See the migration guide for the current state of the deprecation policy.