---
title: "Create Fulfillment"
method: POST
path: "/fulfillments"
tags: ["fulfillments"]
---

# Create Fulfillment

`POST /fulfillments`

## Request

- Base URL: `https://api.instruxi.dev/api/v1/enforcer`
- URL: `https://api.instruxi.dev/api/v1/enforcer/fulfillments`
- Auth: API key in header `Authorization`

Creates a payment intent — a "send" (sender defaults to the caller) or, when sender_user_id or sender_wallet names someone else, a "request" the caller becomes the receiver of. deadline is required RFC3339. memo is the join key to on-chain settlement: 409 memo_taken if it's already held by a live (pending/claimed/submitted) fulfillment in the tenant — omit it and the server mints one. 422 amount_over_limit if a DIRECT-tagged payment exceeds the per-payment cap (max_uoa/max_cents echoed in the error body).

**idempotency_key (optional, 8..255 chars) — pass one if your client can retry.** A second create with the same key in the same tenant returns the ORIGINAL fulfillment, same id, instead of minting a second payment. The key is durable for the row's whole life, so a retry that arrives after the original settled still resolves to it — that is the "response was lost, client retried" case. Without a key, behaviour is unchanged: every call creates a new payment intent. Note the counterparty notification fires only on the real create, never on a replay.

## Request body

- InternalDeliveryHttpHandlerCreateFulfillmentRequest
  - `chain_id` integer
  - `deadline` string: RFC3339
  - `expected_payer_wallet` string: ExpectedPayerWallet restricts funding to ONE wallet: a settlement from any other payer will not confirm this fulfillment. Optional; omitted means anyone may pay it, which is the normal case. Use it when the payer is the point rather than a detail — a refund that must leave the merchant's settlement wallet.
  - `expected_plan_hash` string
  - `idempotency_key` string: IdempotencyKey de-duplicates this create. Optional, but strongly advised for any client that can retry: pass the same key and a second call returns the ORIGINAL fulfillment (200-shaped, same id) instead of minting a second payment. Unique per tenant, 8..255 chars, and durable for the row's whole life — a retry after the original settled still resolves to it.
  - `max_fee_bps` integer
  - `memo` string
  - `notional_uoa` string
  - `receiver_user_id` string
  - `receiver_wallet` string
  - `reference` string
  - `refund_wallet` string: RefundWallet is NOT settable here: a refund destination is chosen only by a trusted service at POST /service/fulfillments. Sending it on this route is refused (403 refund_wallet_service_only) rather than silently dropped.
  - `request_token` string
  - `sender_user_id` string: SenderUserID addresses the payer by account id — used by the "request" flow when the contact has no on-chain wallet on file. Optional: when unset, the sender defaults to the caller (the "send" flow). Mutually optional with sender_wallet.
  - `sender_wallet` string

## Response `201`

Created

- InternalDeliveryHttpHandlerFulfillmentResponse
  - `data` EnforcerV3InternalDomainFulfillmentFulfillment
    - `block_number` integer
    - `chain_id` integer
    - `claimed_at` string
    - `created_at` string
    - `created_by_user_id` string
    - `deadline` string
    - `declined_at` string
    - `declined_by_user_id` string
    - `deleted_at` string
    - `delivery_legs` EnforcerV3InternalDomainFulfillmentDeliveryLeg[]: DeliveryLegs is what the RECEIVER got, which no on-chain event reports — see migration 000077. Neither Routed nor GPVRecorded carries an output token or amount, and the Router's per-leg distribution emits nothing, so this is resolved from Book.getPlan plus the receipt's Transfer logs. PlanVerified gates it: only TRUE means our re-hash matched the event's planHash and the legs are exact. FALSE means inferred (the plan changed under us, or an unrecoverable inlinePlan) and NULL means never attempted. Neither is "probably fine" — 25.6% of settled rows on prod were paid under a plan that has since changed, because a receiver can rewrite their own plan at will (Book.setPlan is ungated on msg.sender).
      - `bps` integer
      - `delivered` string: Delivered is base units of Token, read from the receipt's Transfer logs (the chain publishes it nowhere else). Empty when not resolved.
      - `token` string
      - `wallet` string
    - `expected_payer_wallet` string: ExpectedPayerWallet, when set, is the ONLY wallet that may fund this fulfillment — see PayerBound and migration 000101. Never inferred from an account and never backfilled from whoever paid; sender_wallet is the snapshot/observed field and this is the RULE.
    - `expected_plan_hash` string
    - `expired_at` string
    - `fee_charged` string
    - `id` string
    - `idempotency_key` string: IdempotencyKey de-duplicates CREATE (migration 000082). Optional: when a caller supplies one, a second create with the same (tenant_id, key) returns the ORIGINAL row instead of minting a second payment. Unique per tenant over non-NULL values, for the row's whole lifetime — a retry that arrives after the original settled must still resolve to it, which is exactly the "answer lost, client retried later" case.
    - `input_spent_actual` string
    - `input_token_actual` string
    - `kind` string: Kind is the product line this fulfillment belongs to: p2p | deposit | billpay | direct | reward. Derived server-side from the reference tag at create (ClassifyKind) and stored, rather than re-derived per query from a free-text column. Empty only on rows written before migration 000075 or by a pod still on the previous image; the query layer falls back to the reference for those.
    - `max_fee_bps` integer
    - `memo` string: bytes32 on-chain memo — the join key between this record and chain state.
    - `notional_actual` string
    - `notional_uoa` string
    - `paid_uoa` string: PaidUOA is the summed ROUTED settlements for this row's memo — what actually arrived, against NotionalUOA's what-was-owed. The excess is PaidUOA - NotionalUOA, and it is the reason this field exists: the overpaid filter finds rows where a payer routed the same memo twice or a route declared more than the request asked for, but a boolean cannot tell support HOW MUCH. DERIVED, not a column (gorm:"-"): computed per page by the admin read paths. A POINTER because absent must stay distinguishable from zero — on any endpoint that does not compute it, a plain string would render "0", which reads as "nothing ever arrived" for a fully paid transfer.
    - `payer_address` string
    - `plan_hash_actual` string
    - `plan_verified` boolean
    - `recall_reason` string
    - `recalled_at` string
    - `recalled_by_user_id` string
    - `receiver` EnforcerV3InternalDomainFulfillmentUserDisplay
      - `email` string
      - `email_verified` boolean
      - `first_name` string
      - `is_contact` boolean: IsContact is true when this counterparty is already a saved (linked) contact of the caller viewing the row, so the PWA can badge it without a second lookup. Computed per-viewer; false for the caller themselves.
      - `last_name` string
      - `phone_number` string
      - `phone_number_verified` boolean
      - `user_id` string
      - `username` string
      - `wallet_address` string: WalletAddress is the counterparty's on-file wallet (from their account), so the row renders the send/receive wallet alongside the name.
    - `receiver_paid_ack_at` string
    - `receiver_seen_at` string
    - `receiver_user_id` string
    - `receiver_wallet` string
    - `reference` string
    - `refund_wallet` string: RefundWallet, when set, is where a refund of this sale is sent instead of the wallet that paid it (migration 000116). Set only at create, by a service caller; exposed on reads beside receiver_wallet so the destination can be shown.
    - `refunded_uoa` string: RefundedUOA is how much has actually been refunded AGAINST this payment: the summed routed settlements of every refund naming it. Same shape and the same reason as PaidUOA — a boolean "refunded" cannot answer "how much", and partial refunds are the normal case. A POINTER because absent must stay distinguishable from zero: on an endpoint that does not compute it, a plain string would render "0", which reads as "never refunded" for a fully refunded payment.
    - `refunds_fulfillment_id` string: RefundsFulfillmentID is the payment this one REVERSES, parsed from the reference. Derived, not a column: the reference is the record, and a second stored copy could disagree with it.
    - `request_token` string
    - `sender` EnforcerV3InternalDomainFulfillmentUserDisplay
      - `email` string
      - `email_verified` boolean
      - `first_name` string
      - `is_contact` boolean: IsContact is true when this counterparty is already a saved (linked) contact of the caller viewing the row, so the PWA can badge it without a second lookup. Computed per-viewer; false for the caller themselves.
      - `last_name` string
      - `phone_number` string
      - `phone_number_verified` boolean
      - `user_id` string
      - `username` string
      - `wallet_address` string: WalletAddress is the counterparty's on-file wallet (from their account), so the row renders the send/receive wallet alongside the name.
    - `sender_cost_uoa` string: SenderCostUOA is value_at_mid(input_spent_actual) - notional_uoa: what the route cost the sender ABOVE what was delivered. fee_charged is only part of it — on the Aug-26 route the fee was 2.99 USDC and the true cost 13.18. SlippageUOA is the residual after the fee. ★ It is NOT always "lost": on a DIRECT same-token leg the principal never swaps, so the gap is a transfer to the RECEIVER rather than a loss to liquidity providers. Check DeliveryLegs before calling it a loss. Negative means the route beat mid, and it is stored signed so a better-than-mid execution is not clamped away. NULL means NOT COMPUTED — no pool for the input token, an RPC failure, or a row predating migration 000081. It never means zero.
    - `sender_seen_at` string
    - `sender_user_id` string
    - `sender_wallet` string
    - `settled_at` string
    - `slippage_uoa` string
    - `status` string
    - `submitted_at` string
    - `tenant_id` string
    - `tx_hash` string
    - `updated_at` string
  - `success` boolean

## Other responses

- `400`: Bad Request
- `401`: Unauthorized
- `409`: memo_taken — the supplied memo is already in use by a live (pending/claimed/submitted) fulfillment in this tenant. memo is the join key to on-chain settlement; omit it and the server mints one.
- `422`: amount_over_limit — a DIRECT-tagged payment over the per-payment cap (max_uoa + max_cents in payload)

---

[API](https://skmtc.dev/instruxi/apis/enforcer-api.md) · [All operations](https://skmtc.dev/instruxi/apis/enforcer-api/llms.txt) · [OpenAPI document](https://skmtc.dev/instruxi/apis/enforcer-api/revisions/fa82bc1becc0?raw)
