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 listing

The 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)

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)

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])

In .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 409 with title: Idempotency conflict means the previous attempt is still in progress: wait and retry with the same key.
  • Any other 4xx (for example 400 because the affiliate has no payment method, or 409 because the allowance is no longer pending) is not retried: flag it for manual review.
  • The webhook’s event_id is 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"]))

Relaunching 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).

JSON
{
  "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):

Handler branch
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)

6. Close the period #

The period is closed when:

  • list(period="2026-10", status="pending") returns count: 0, and
  • the number and total of PAGADA allowances in your ERP match the monetary_allowance.disbursed events you received and list(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 #

  • generate responds with eligible: true, amount: 50000 and an id mad-E-… (with extra={"document_number": "1000000002"} you’ll see a NOT_ELIGIBLE).
  • list always returns one sample allowance for the period in GENERATED status (the sandbox keeps no state, so it doesn’t filter by pending): to test the payment loop, use the disbursement_id from that row or the one generate returned.
  • disburse on a mad-E-… id responds DISBURSED; on one from an ineligible profile (mad-I-…, mad-D-…), 409.
  • Repeating disburse with the same key returns idempotent_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.