Illustrative example. The batch, timestamps, payees, counts, provider responses and code in this post are a composite built for explanation. They do not describe a Gruv production incident, Gruv's production API, or the behaviour of any specific payment provider.
A team submits 312 payouts at 09:40 on a Thursday. Every item has passed their JSON Schema validation: required fields present, amounts as integers, currencies as three-letter codes, references within the length limit. Schema validation had already rejected 31 malformed rows from the export before the batch was built, so what remains looks clean. The provider accepts the file.
At 10:21 the item statuses come back. 289 are in flight. 23 are held for review under the provider's own validation policy.
None of the 23 failed the schema. Nine are inward remittances to India where the purpose code is missing on seven items and malformed on two. Eight are ACH credits where the receiver name is longer than the ACH name field. Four are SEPA credit transfers whose settlement currency was left as US dollars, copied straight from the invoice, instead of being set to euro after conversion. The last two are the same invoice from the same payee entered twice, because a retry upstream appended rows instead of replacing them.
The provider in this example holds those items. A different provider might reject them outright, truncate a field, or let some through. The rail format defines what a valid field looks like, but what happens to an invalid one is provider policy, and that distinction matters for everything below.
What JSON Schema can express
A schema is usually described as a shape check: receiver_name is a string, settlement_currency matches ^[A-Z]{3}$, amount_minor is a positive integer. It can do more than that. JSON Schema's conditional keywords (if, then and else since draft 7, plus dependentRequired and dependentSchemas) let one field constrain another. The SEPA currency rule fits in a few lines:
{
"if": {
"properties": { "rail": { "const": "sepa_credit_transfer" } },
"required": ["rail"]
},
"then": {
"properties": { "settlement_currency": { "const": "EUR" } }
}
}
So the case for a separate pre-flight layer is not that a schema is incapable. It is about who owns the rules and how failures are reported. The schema is an API contract, usually owned by the integration team and versioned with the code. Rail rules and provider policies are owned by whoever runs payments, they change when a rulebook or a provider changes, and the people fixing a held item want a business reason such as "purpose code missing", not a validator path pointing into an allOf branch. Keeping those rules as data in one place, each with a name and a source, makes them easier to review and to update without touching the contract.
Rail formats, provider policy and business rules
Each of the four hold reasons in this example comes from a different place, and it helps to keep them apart.
The ACH name limit is a file-format fact. In the Nacha Entry Detail record, the receiver's name occupies positions 55 to 76, which is 22 characters, and the Nacha ACH developer guide describes it as information entered by the originator to help identify the receiver. Northfield Landscaping and Snow Removal LLC is 43 characters. The format does not say a longer name must be held; that decision belongs to the originating software and the provider. In this example the provider holds it, and that is the only reason the item counts as one of the 23.
The SEPA problem is about which currency the transfer settles in, not which currency the invoice was issued in. A euro-area supplier can perfectly well invoice in US dollars. SEPA Credit Transfer is a euro scheme, as set out in the EPC SEPA Credit Transfer rulebooks, so a payout that travels on it needs a euro amount, which means a conversion step somewhere. The four held items skipped that step and carried the invoice currency into the transfer instruction.
The India rule applies to one workflow. When a payment is received into India from abroad, it is reported with a purpose code from the Reserve Bank of India's list, a P followed by four digits. In the RBI's purpose code list for receipts, P0802 is "Software implementation/consultancy (other than those covered in SOFTEX form)". In this example the payer stores the code as free text, and two records hold 802. None of this means every domestic INR payout needs a purpose code; the check below applies only to items on the inward-remittance route.
The duplicate is a business rule. No rail defines it. Two rows count as the same payment when they share a payer, a payee and an invoice number. A repeated free-text reference is not enough evidence on its own, because references get reused, edited and truncated, and two payees can easily issue invoices with the same number.
An illustrative pre-flight checker
The checker below runs after schema validation and before submission. Rules are data, so adding a rail means adding an entry rather than another branch. It is teaching code with simplified rules and sample data, not a production validator.
"""Illustrative pre-flight checks for a payout batch.
This is teaching code, not a production validator, and it does not describe
any provider's API. The rail and provider rules below are simplified examples.
Amounts are integer minor units only because that keeps the example simple.
"""
import re
# Simplified rules for this example. Real constraints come from the rail's
# documentation and from your provider's validation and hold policies.
RAIL_RULES = {
"ach": {
"settlement_currency": "USD",
"max_receiver_name": 22, # Nacha Entry Detail record, positions 55-76
},
"sepa_credit_transfer": {
"settlement_currency": "EUR",
},
"india_inward_remittance": {
"needs_purpose_code": True,
},
}
# Checks the shape of an RBI purpose code only. It cannot tell you whether
# the code exists, or whether it is the right one for the transaction.
PURPOSE_CODE_SHAPE = re.compile(r"^P\d{4}$")
def preflight(items):
"""Return (item_id, finding, detail) for every rule an item breaks.
Every applicable check runs for every item, so a payee with two problems
is reported once with both, and an unknown rail does not hide a duplicate.
"""
findings = []
seen_invoices = {}
for item in items:
item_id = item["id"]
rule = RAIL_RULES.get(item["rail"])
if rule is None:
findings.append((item_id, "UNKNOWN_RAIL", item["rail"]))
rule = {}
expected = rule.get("settlement_currency")
if expected and item["settlement_currency"] != expected:
findings.append((item_id, "SETTLEMENT_CURRENCY_NOT_ON_RAIL",
f"{item['settlement_currency']} on {item['rail']}, "
f"invoice in {item['invoice_currency']}"))
limit = rule.get("max_receiver_name")
if limit and len(item["receiver_name"]) > limit:
findings.append((item_id, "RECEIVER_NAME_EXCEEDS_FIELD",
f"{len(item['receiver_name'])} characters, field holds {limit}"))
if rule.get("needs_purpose_code"):
code = item.get("purpose_code")
if not code:
findings.append((item_id, "PURPOSE_CODE_MISSING", ""))
elif not PURPOSE_CODE_SHAPE.match(code):
findings.append((item_id, "PURPOSE_CODE_FORMAT", repr(code)))
# A duplicate is the same invoice, from the same payee, paid by the
# same payer. A repeated free-text reference is not enough on its own.
invoice_key = (item["payer_id"], item["payee_id"], item["invoice_number"])
if invoice_key in seen_invoices:
findings.append((item_id, "DUPLICATE_INVOICE",
f"same invoice as {seen_invoices[invoice_key]}"))
else:
seen_invoices[invoice_key] = item_id
return findings
SAMPLE_BATCH = [
{"id": "PO-88310", "rail": "ach", "payer_id": "PAYER-01", "payee_id": "V-1001",
"invoice_number": "INV-40211", "invoice_currency": "USD",
"settlement_currency": "USD", "amount_minor": 184_700,
"receiver_name": "Northfield Landscaping and Snow Removal LLC"},
{"id": "PO-88311", "rail": "india_inward_remittance", "payer_id": "PAYER-01",
"payee_id": "V-2040", "invoice_number": "INV-40212", "invoice_currency": "USD",
"settlement_currency": "INR", "amount_minor": 21_450_000,
"receiver_name": "Meera Raghunathan"},
{"id": "PO-88312", "rail": "sepa_credit_transfer", "payer_id": "PAYER-01",
"payee_id": "V-3107", "invoice_number": "INV-40213", "invoice_currency": "USD",
"settlement_currency": "USD", "amount_minor": 312_000,
"receiver_name": "Aodhan O Briain"},
{"id": "PO-88313", "rail": "sepa_credit_transfer", "payer_id": "PAYER-01",
"payee_id": "V-3152", "invoice_number": "INV-40214", "invoice_currency": "USD",
"settlement_currency": "EUR", "amount_minor": 96_240,
"receiver_name": "Vestergaard Studio ApS"},
{"id": "PO-88314", "rail": "india_inward_remittance", "payer_id": "PAYER-01",
"payee_id": "V-2077", "invoice_number": "INV-40215", "invoice_currency": "USD",
"settlement_currency": "INR", "amount_minor": 8_800_000,
"receiver_name": "Kavya Iyer", "purpose_code": "802"},
{"id": "PO-88315", "rail": "sepa_credit_transfer", "payer_id": "PAYER-01",
"payee_id": "V-3152", "invoice_number": "INV-40214", "invoice_currency": "USD",
"settlement_currency": "EUR", "amount_minor": 96_240,
"receiver_name": "Vestergaard Studio ApS"},
{"id": "PO-88316", "rail": "sepa_credit_transfer", "payer_id": "PAYER-01",
"payee_id": "V-3198", "invoice_number": "INV-40214", "invoice_currency": "EUR",
"settlement_currency": "EUR", "amount_minor": 41_000,
"receiver_name": "Brandt Akustik GmbH"},
{"id": "PO-88317", "rail": "rtp", "payer_id": "PAYER-01", "payee_id": "V-1001",
"invoice_number": "INV-40211", "invoice_currency": "USD",
"settlement_currency": "USD", "amount_minor": 184_700,
"receiver_name": "Northfield Landscaping"},
]
if __name__ == "__main__":
for item_id, finding, detail in preflight(SAMPLE_BATCH):
print(f"{item_id} {finding:<32} {detail}".rstrip())
Running it prints:
PO-88310 RECEIVER_NAME_EXCEEDS_FIELD 43 characters, field holds 22
PO-88311 PURPOSE_CODE_MISSING
PO-88312 SETTLEMENT_CURRENCY_NOT_ON_RAIL USD on sepa_credit_transfer, invoice in USD
PO-88314 PURPOSE_CODE_FORMAT '802'
PO-88315 DUPLICATE_INVOICE same invoice as PO-88313
PO-88317 UNKNOWN_RAIL rtp
PO-88317 DUPLICATE_INVOICE same invoice as PO-88310
The sample is built to exercise each rule rather than to reproduce all 23 holds. A few of its results are worth checking against the code. PO-88313 is invoiced in dollars but settles in euro, so it passes. PO-88316 reuses the invoice number INV-40214 but comes from a different payee, so it is not a duplicate. PO-88317 is on a rail the table does not know, and the duplicate check still runs and reports it.
Two limits are built into this design on purpose. The purpose-code regex checks shape only: P9999 would pass it, and so would a real code that is wrong for the transaction. And the checker never edits an item. It reports, and a person or an upstream job decides what to change.
Should a long ACH name be truncated automatically?
Cutting every ACH name to 22 characters is tempting, and for some names it is harmless: Northfield Landscaping and Snow Removal LLC becomes Northfield Landscaping. For others it changes what the record says. The Ahmed and Okonkwo Family Trust becomes The Ahmed and Okonkwo with a trailing space, which on a statement reads like a different and smaller party. How much the name field matters once the payment arrives varies between banks and providers, so a truncation rule that is safe in one context is not automatically safe in another.
One reasonable policy is to flag the item and have someone record an approved short name on the payee once. That adds a manual step for every new long-named ACH payee, and it is a cost worth stating plainly before choosing it.
Keeping the rules current
RAIL_RULES is a copy. The real rules live in the Nacha formats, the EPC rulebooks, the RBI's purpose code list and whatever each provider layers on top, and every one of those changes on its own schedule. Recording, for each entry, the document it came from and when someone last reviewed it turns a silent drift into a visible date.
Provider rules are the part no public document fully covers, so ask for them in writing during evaluation: which fields are validated, what is held rather than rejected, and what is truncated. If you are evaluating Gruv, its developer evaluation page is explicit about scope: the public tools help frame an integration conversation, and production schemas, authentication and sandbox access are confirmed with the team rather than published.
Write the answers into the same table as the rail rules, with the date you received them, so a provider's policy and a rulebook's format sit side by side and can be reviewed together.
For sketching request shapes before you have that access, Gruv's payload builder that checks the required fields is a generic tool. Its field names are illustrative rather than any live API schema, so it helps with the shape of a request and says nothing about the rail and provider rules covered here.
Where to start
Take last month's held or rejected payouts, group them by reason, and separate the rail-format causes from the provider-policy causes and the business rules. Then write one pre-flight rule for the largest group, record where the rule came from, and add the next one only when the first is earning its place.
Top comments (3)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.