Recipe
Monetary allowance for the period
Close the month’s monetary allowance: simulate with dry_run, review, generate for real, list every allowance page by page and disburse each one with its stable Idempotency-Key, with safe retries and the monetary_allowance.disbursed webhook.
On this page
Every month, Caja Horizonte calculates the monetary allowance for its affiliates and pays it out. A mistake here is expensive: paying thousands of affiliates twice, or leaving half of them unpaid because the process crashed halfway through the batch. This recipe closes the October 2026 period in a way that can be interrupted and relaunched at any point without paying twice.
You need an API key with subsidies:submit (generate), subsidies:read (list) and
subsidies:disburse (pay), an endpoint subscribed to monetary_allowance.disbursed, and the
Subsidies and monetary allowance module at hand.
The process #
sequenceDiagram
autonumber
participant J as Closing job (Caja Horizonte)
participant G as Compense Gateway
participant H as Webhook handler
J->>G: generate(period=2026-10, dry_run=true)
G-->>J: simulated calculation
Note over J: compare with your own calculation (affiliates, total)
J->>G: generate(period=2026-10) · stable Idempotency-Key
G-->>J: final generation
loop page by page
J->>G: list(period=2026-10, status=pending, page=n)
end
Note over J: store ALL disbursement_id values before paying
loop for each allowance
J->>G: disburse(disbursement_id) · key = horizonte-cuota-{id}
G--)H: monetary_allowance.disbursed
end
Note over J,H: close: paid per webhook = paid in the listingThe golden rule: one Idempotency-Key per business operation, derived from stable data
(the period, the disbursement_id), never a new UUID per attempt. That way, relaunching the whole job
is harmless: whatever was already done comes back as idempotent_replay: true.
1. Simulate with dry_run #
Run the simulation as many times as you need while you adjust the month’s changes; each simulation
gets its own key (…-simulacion-1, …-simulacion-2), because reusing the same key would return
the response from the first one.
sim = client.monetary_allowance.generate(
period="2026-10", dry_run=True, idempotency_key="horizonte-cuota-2026-10-simulacion-1")
print(sim.transaction_id, sim.data)const sim = await client.monetaryAllowance.generate({
period: "2026-10", dryRun: true, idempotencyKey: "horizonte-cuota-2026-10-simulacion-1",
});
console.log(sim.transactionId, sim.data);$sim = $client->monetaryAllowance->generate(
period: '2026-10', dryRun: true, idempotencyKey: 'horizonte-cuota-2026-10-simulacion-1');
print_r($sim->data);Transaction sim = await client.MonetaryAllowance.GenerateAsync(
"2026-10", dryRun: true, idempotencyKey: "horizonte-cuota-2026-10-simulacion-1");
Console.WriteLine(sim.Data.ToJsonString());sim, err := client.MonetaryAllowance.Generate(ctx,
&compense.MonetaryAllowanceGenerateParams{Period: "2026-10", DryRun: true},
compense.WithIdempotencyKey("horizonte-cuota-2026-10-simulacion-1"))
if err != nil {
log.Fatal(err)
}
fmt.Println(sim.TransactionID, sim.Data)curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/subsidies/monetary-allowance/generate/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-cuota-2026-10-simulacion-1" \
-H "Content-Type: application/json" \
-d '{"period": "2026-10", "dry_run": true}'Review before you continue: check that the number of eligible affiliates and the total match the calculation from your subsidies team, and that the ineligible ones have a reason you recognize.
dry_run in production
In the sandbox, dry_run doesn’t change the response (the sandbox never persists anything). Before
you run the simulation against your real data, confirm with your Compense account manager that your
entity’s engine honors dry_run; if it doesn’t, do the review on your own calculation and go
straight to step 2.
2. Generate for real #
Use a single key for the period’s generation. If the job crashes and you relaunch it with the same key, you get the original response instead of generating again.
gen = client.monetary_allowance.generate(
period="2026-10", idempotency_key="horizonte-cuota-2026-10-generacion")
print(gen.transaction_id, gen.idempotent_replay, gen.data)const gen = await client.monetaryAllowance.generate({
period: "2026-10", idempotencyKey: "horizonte-cuota-2026-10-generacion",
});
console.log(gen.transactionId, gen.idempotentReplay, gen.data);$gen = $client->monetaryAllowance->generate(
period: '2026-10', idempotencyKey: 'horizonte-cuota-2026-10-generacion');
echo $gen->transactionId, ' ', var_export($gen->idempotentReplay, true), PHP_EOL;Transaction gen = await client.MonetaryAllowance.GenerateAsync(
"2026-10", idempotencyKey: "horizonte-cuota-2026-10-generacion");
Console.WriteLine($"{gen.TransactionId} {gen.IdempotentReplay} {gen.Data.ToJsonString()}");gen, err := client.MonetaryAllowance.Generate(ctx,
&compense.MonetaryAllowanceGenerateParams{Period: "2026-10"},
compense.WithIdempotencyKey("horizonte-cuota-2026-10-generacion"))
if err != nil {
log.Fatal(err)
}
fmt.Println(gen.TransactionID, gen.IdempotentReplay, gen.Data)curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/subsidies/monetary-allowance/generate/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-cuota-2026-10-generacion" \
-H "Content-Type: application/json" \
-d '{"period": "2026-10", "dry_run": false}'If your entity’s engine calculates per affiliate, repeat this step for each affiliate with
extra={"document_number": …} and one key per affiliate and period
(horizonte-cuota-2026-10-<documento>); see
Generate for a specific affiliate.
3. List every pending allowance (paginating) #
Walk through every page and store the disbursement_id values before you start paying: if
you pay while paginating with status=pending, each payment removes rows from the filter and the
following pages shift, so you would skip allowances.
pendientes = []
page_no = 1
while True:
page = client.monetary_allowance.list(period="2026-10", status="pending", page=page_no, page_size=100)
pendientes.extend(page.results)
if not page.next:
break
page_no += 1
print(len(pendientes), "allowances to pay")
erp.guardar_lote("2026-10", [(c["disbursement_id"], c["amount"]) for c in pendientes])const pendientes: Array<Record<string, unknown>> = [];
const first = await client.monetaryAllowance.list({ period: "2026-10", status: "pending", pageSize: 100 });
for await (const row of first) pendientes.push(row); // walks through every page
console.log(pendientes.length, "allowances to pay");
await erp.guardarLote("2026-10", pendientes.map((c) => [String(c.disbursement_id), String(c.amount)]));$pendientes = [];
$pageNo = 1;
do {
$page = $client->monetaryAllowance->list(period: '2026-10', page: $pageNo, pageSize: 100, status: 'pending');
array_push($pendientes, ...$page->results);
$pageNo++;
} while ($page->hasNext());
echo count($pendientes), " allowances to pay\n";
$erp->guardarLote('2026-10', $pendientes);var pendientes = new List<JsonObject>();
int pageNo = 1;
Page page;
do
{
page = await client.MonetaryAllowance.ListAsync("2026-10", pageNo, 100, "pending");
pendientes.AddRange(page.Results);
pageNo++;
} while (page.Next is not null);
Console.WriteLine($"{pendientes.Count} allowances to pay");
await erp.GuardarLoteAsync("2026-10", pendientes);it := compense.NewPageIterator(ctx, func(ctx context.Context, page int) (*compense.Page, error) {
return client.MonetaryAllowance.List(ctx, &compense.MonetaryAllowanceListParams{
Period: "2026-10", Status: "pending", Page: page, PageSize: 100,
})
})
var pendientes []map[string]any
for it.Next() {
pendientes = append(pendientes, it.Item())
}
if err := it.Err(); err != nil {
log.Fatal(err)
}
fmt.Println(len(pendientes), "allowances to pay")
erp.GuardarLote("2026-10", pendientes)PAGE=1
: > pendientes.txt
while :; do
RESP=$(curl -s "https://sandbox.dev.compense.co/gw/v1/gateway/subsidies/monetary-allowance/?period=2026-10&status=pending&page=$PAGE&page_size=100" \
-H "X-API-Key: $COMPENSE_API_KEY")
echo "$RESP" | jq -r '.results[].disbursement_id' >> pendientes.txt
[ "$(echo "$RESP" | jq -r .next)" = "null" ] && break
PAGE=$((PAGE + 1))
done
wc -l pendientes.txtIn .NET, JsonObject is System.Text.Json.Nodes.JsonObject. Each row includes at least
disbursement_id, period, amount and status; store them in your ERP with a status of your own
(POR_PAGAR).
4. Disburse each allowance with safe retries #
For each allowance, use a key derived from the disbursement_id (horizonte-cuota-<id>). With
that:
- A retry after a transient error (
502,503,504,429, network down) uses the same key and never pays twice. The SDKs already retry those cases with exponential backoff; the loop below covers longer outages. - A
409withtitle: Idempotency conflictmeans the previous attempt is still in progress: wait and retry with the same key. - Any other
4xx(for example400because the affiliate has no payment method, or409because the allowance is no longer pending) is not retried: flag it for manual review. - The webhook’s
event_idis derived from the key, so you won’t receive duplicate events either.
import time
from compense_gateway import CompenseAPIError, IdempotencyConflictError, RateLimitError, UpstreamError
def pagar(cuota_id: str) -> str:
llave = f"horizonte-cuota-{cuota_id}"
for intento in range(5):
try:
tx = client.monetary_allowance.disburse(cuota_id, idempotency_key=llave)
return "PAGADA"
except (UpstreamError, RateLimitError):
time.sleep(2 ** intento) # transient: same key
except IdempotencyConflictError as e:
if e.title != "Idempotency conflict":
return f"REVISAR: {e.detail}"
time.sleep(2 ** intento) # still in progress: same key
except CompenseAPIError as e:
return f"REVISAR: {e.status} {e.detail}"
return "REINTENTAR_DESPUES"
for c in pendientes:
erp.actualizar_cuota(c["disbursement_id"], pagar(c["disbursement_id"]))import { CompenseAPIError, IdempotencyConflictError, RateLimitError, UpstreamError } from "@compense/sdk";
const dormir = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function pagar(cuotaId: string): Promise<string> {
const idempotencyKey = `horizonte-cuota-${cuotaId}`;
for (let intento = 0; intento < 5; intento++) {
try {
await client.monetaryAllowance.disburse(cuotaId, { idempotencyKey });
return "PAGADA";
} catch (err) {
if (err instanceof UpstreamError || err instanceof RateLimitError) await dormir(2 ** intento * 1000);
else if (err instanceof IdempotencyConflictError && err.title === "Idempotency conflict") await dormir(2 ** intento * 1000);
else if (err instanceof CompenseAPIError) return `REVISAR: ${err.status} ${err.detail}`;
else throw err;
}
}
return "REINTENTAR_DESPUES";
}
for (const c of pendientes) {
await erp.actualizarCuota(String(c.disbursement_id), await pagar(String(c.disbursement_id)));
}use Compense\Sdk\Exception\CompenseApiException;
use Compense\Sdk\Exception\IdempotencyConflictException;
use Compense\Sdk\Exception\RateLimitException;
use Compense\Sdk\Exception\UpstreamException;
function pagar(\Compense\Sdk\CompenseClient $client, string $cuotaId): string
{
$llave = "horizonte-cuota-{$cuotaId}";
for ($intento = 0; $intento < 5; $intento++) {
try {
$client->monetaryAllowance->disburse($cuotaId, idempotencyKey: $llave);
return 'PAGADA';
} catch (UpstreamException | RateLimitException) {
sleep(2 ** $intento);
} catch (IdempotencyConflictException $e) {
if ($e->title !== 'Idempotency conflict') {
return "REVISAR: {$e->detail}";
}
sleep(2 ** $intento);
} catch (CompenseApiException $e) {
return "REVISAR: {$e->status} {$e->detail}";
}
}
return 'REINTENTAR_DESPUES';
}
foreach ($pendientes as $c) {
$erp->actualizarCuota($c['disbursement_id'], pagar($client, $c['disbursement_id']));
}async Task<string> PagarAsync(string cuotaId)
{
string llave = $"horizonte-cuota-{cuotaId}";
for (int intento = 0; intento < 5; intento++)
{
try
{
await client.MonetaryAllowance.DisburseAsync(cuotaId, idempotencyKey: llave);
return "PAGADA";
}
catch (Exception e) when (e is UpstreamException or RateLimitException
|| (e is IdempotencyConflictException c && c.Title == "Idempotency conflict"))
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, intento))); // same key
}
catch (CompenseApiException e)
{
return $"REVISAR: {e.Status} {e.Detail}";
}
}
return "REINTENTAR_DESPUES";
}
foreach (var c in pendientes)
{
string id = c["disbursement_id"]!.GetValue<string>();
await erp.ActualizarCuotaAsync(id, await PagarAsync(id));
}pagar := func(cuotaID string) string {
llave := compense.WithIdempotencyKey("horizonte-cuota-" + cuotaID)
for intento := 0; intento < 5; intento++ {
_, err := client.MonetaryAllowance.Disburse(ctx, cuotaID, llave)
var apiErr *compense.APIError
switch {
case err == nil:
return "PAGADA"
case compense.IsUpstreamError(err), compense.IsRateLimited(err):
time.Sleep(time.Duration(1<<intento) * time.Second) // transient: same key
case compense.IsIdempotencyConflict(err) && errors.As(err, &apiErr) && apiErr.Title == "Idempotency conflict":
time.Sleep(time.Duration(1<<intento) * time.Second) // still in progress: same key
case errors.As(err, &apiErr):
return fmt.Sprintf("REVISAR: %d %s", apiErr.StatusCode, apiErr.Detail)
default:
time.Sleep(time.Duration(1<<intento) * time.Second) // network: same key
}
}
return "REINTENTAR_DESPUES"
}
for _, c := range pendientes {
id := fmt.Sprint(c["disbursement_id"])
erp.ActualizarCuota(id, pagar(id))
}while read -r ID; do
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
"https://sandbox.dev.compense.co/gw/v1/gateway/subsidies/monetary-allowance/$ID/disburse/" \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: horizonte-cuota-$ID")
echo "$ID $CODE" # 200 = paid (or replay); 5xx/429 = retry with the SAME key
done < pendientes.txtRelaunching the whole loop after a crash is safe: allowances that were already paid return
idempotent_replay: true without paying again. If you want to parallelize, do it by allowance
(never two threads on the same allowance) and respect your key’s rate limits.
5. Confirm via webhook #
Each payment emits monetary_allowance.disbursed. Its resource.allowance_id is the
disbursement_id you stored in step 3: use it to mark the allowance as PAGADA in your ERP even if
the job didn’t get to store the response (for example, if it crashed right after paying).
{
"event": "monetary_allowance.disbursed",
"version": "1",
"tenant_id": "horizonte",
"occurred_at": "2026-11-05T09:00:00Z",
"resource": {
"allowance_id": "5b2e9c1d-7f3a-4d8b-a6c2-9e0f1a2b3c4d",
"disbursement_id": "5b2e9c1d-7f3a-4d8b-a6c2-9e0f1a2b3c4d",
"amount": "47000.00",
"currency": "COP",
"method": "wallet",
"disbursed_at": "2026-11-05T09:00:00Z",
"status": "disbursed"
}
}The handler branch (the full handler, with signature verification and deduplication, is in the Disburse a credit and reconcile recipe):
if ev.event == "monetary_allowance.disbursed":
r = ev.resource
erp.marcar_cuota_pagada(r["allowance_id"], monto=r["amount"], pagada_en=r["disbursed_at"], evento=ev.event_id)if (ev.event === "monetary_allowance.disbursed") {
const r = ev.resource as Record<string, string>;
await erp.marcarCuotaPagada(r.allowance_id, r.amount, r.disbursed_at, ev.eventId);
}if ($ev->event() === 'monetary_allowance.disbursed') {
$r = $ev->resource();
$erp->marcarCuotaPagada($r['allowance_id'], $r['amount'], $r['disbursed_at'], $ev->eventId);
}if (ev.Event == "monetary_allowance.disbursed")
{
var r = ev.Resource;
await erp.MarcarCuotaPagadaAsync(r["allowance_id"]!.GetValue<string>(),
r["amount"]!.GetValue<string>(), r["disbursed_at"]!.GetValue<string>(), ev.EventId);
}if ev.Event() == "monetary_allowance.disbursed" {
r := ev.Resource()
erp.MarcarCuotaPagada(ctx, fmt.Sprint(r["allowance_id"]), fmt.Sprint(r["amount"]), fmt.Sprint(r["disbursed_at"]), ev.ID)
}# Check your deliveries for this event (e.g. to see whether any of them failed):
curl "https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/deliveries/?event_type=monetary_allowance.disbursed" \
-H "X-API-Key: $COMPENSE_API_KEY"6. Close the period #
The period is closed when:
list(period="2026-10", status="pending")returnscount: 0, and- the number and total of
PAGADAallowances in your ERP match themonetary_allowance.disbursedevents you received andlist(period="2026-10", status="disbursed").
Anything left in REVISAR is resolved by the subsidies team (the affiliate’s payment method,
monthly changes) and paid with the same key once it’s ready. Allowances that nobody claims move
to prescribed when their deadline expires.
In the sandbox #
generateresponds witheligible: true,amount: 50000and an idmad-E-…(withextra={"document_number": "1000000002"}you’ll see aNOT_ELIGIBLE).listalways returns one sample allowance for the period inGENERATEDstatus (the sandbox keeps no state, so it doesn’t filter bypending): to test the payment loop, use thedisbursement_idfrom that row or the onegeneratereturned.disburseon amad-E-…id respondsDISBURSED; on one from an ineligible profile (mad-I-…,mad-D-…),409.- Repeating
disbursewith the same key returnsidempotent_replay: true: it’s the best way to check that your loop is safe before going to production.
More data in Test data, and the theory in Idempotency and Retries.