← BACK TO INSIGHTS
Payments

PayTo and NPP With Monoova: Lessons From a Real Build

Published 2026-10-07 by Ryan Brooker

In short

"What building on Monoova's PayTo, NPP and Automatcher APIs taught me: next-day agreements, webhooks that never arrive, idempotency, and how to stop a double payment."

2Pay moves money between Australian medical practices and the doctors who work in them. The money side runs on Monoova, an Australian payments provider. 2Pay uses PayTo to collect, the New Payments Platform to pay out, and Automatcher accounts to receive.

I'm the only engineer on 2Pay. The surprise in this part of the build was where the work went. Sending a payment takes a few lines of code. Most of the effort went into the cases where the bank and our database disagree: a timeout, a webhook that never arrives, a field the docs spell one way and the API another.

This post covers the design and the real problems, with dates. If you're about to build on PayTo or NPP, it should save you a few weeks.

The build in numbers

MeasureValue
API paths in Monoova's Payments and PayTo specs63
Monoova operations 2Pay calls19
Webhook event types handled9
Recurring jobs that call or check Monoova8
Tests in the Monoova, PayTo and webhook test files355
Money-path issues found by a pessimistic audit in July 202621, five of them launch blockers, all fixed the same day
Live Monoova credentialsSeptember 2026
First production setup6 October 2026

What Monoova does for 2Pay

Monoova productWhat 2Pay uses it for
mAccount2Pay's own transaction account, the source of payouts
AutomatcherA virtual BSB and account number for each practice, where PayTo pulls land
PayToAgreements with doctors and practices, and monthly subscription debits
NPP payoutsPaying doctors, and paying practices their combined fees
Status and statementsSettling unknown outcomes, and a nightly statement check
WebhooksSettlement, dishonour, return and agreement events
Ledger AccountsA separate, labelled balance for each practice, being added now

The Australian Payments Plus pages on PayTo and NPP explain the rails themselves. Monoova's developer docs cover its APIs.

How does the money move?

Patient and Medicare money never passes through 2Pay. It goes straight into the practice's or the doctor's own bank account. 2Pay only pulls money under a PayTo agreement the payer has approved in their own banking app.

There are two main flows:

  • The doctor pays the practice. Patients pay the doctor directly. 2Pay pulls only the service fee from each doctor by PayTo, into the practice's Automatcher account, then sends the practice one NPP payout.
  • The practice pays the doctors. For each pay run, 2Pay pulls the doctors' net pay from the practice's account in one PayTo debit, then pays each doctor by NPP. Once a pull has cleared, it's never pulled again.

Why so much care over whose money is whose? In 2023, the NSW Court of Appeal's Thomas and Naaz decision found that payments a medical centre made to doctors could be taxed as wages for payroll tax. 2Pay's flows were designed with that in mind. Practices should still get their own tax advice.

Why can't a PayTo agreement be used on the day it's created?

Because with Monoova, the earliest you can debit a new PayTo agreement is 00:00 Sydney time the next day. Ask for a start date of today and the create call fails with PAM_STARTDATE_EARLY. Try to pull before the start date and you get PAS_AGREEMENT_NOT_STARTED.

It's a business rule, not a bug, so model it. Every debit path in 2Pay checks the agreement's start date first, and the screen shows "Active from" with the date, so nobody expects money that can't move yet. This went in on 17 August 2026, after the first subscription debit in the test environment ran into it.

What happens when a payment webhook never arrives?

Unless something else is checking, the payment just sits there. Monoova's NPP status webhook only fires for payments that went through a Pending state. A payout that settles instantly may never send one.

That happened in testing on 19 August 2026. A payout settled straight away, no webhook came, and the payout stayed "in progress" with the money held and alerts repeating. The fix, merged on 31 August, was a sweep that asks Monoova for the status of anything stuck. If Monoova says Complete, 2Pay settles it and logs one information alert. Repeat alerts for the same problem now update one open alert instead of creating new ones.

The rule I took from it: webhooks are the fast path, never the only path. Every state a webhook can change has a scheduled check behind it.

JobWhen it runs
Payout sweepEvery 15 minutes
Fee sweepEvery 15 minutes
Sweep for stuck paymentsEvery 30 minutes
Internal trial balance9:30 pm Sydney time
Monoova statement check9:45 pm
End of day summary10 pm
Webhook subscription audit7 am

That last one matters more than it looks. Webhook subscriptions live in Monoova's portal, not in your code. If one goes missing, nothing settles and nothing errors. The 7 am job compares the nine expected events with what's registered.

How do you stop a payments platform paying someone twice?

Treat a timeout as unknown, not failed, and never send again under a new reference until you know the first attempt didn't go through.

A timeout after you send a payment doesn't mean it failed. Mark it failed, retry with a fresh reference, and you can pay a doctor twice. The July 2026 audit found exactly that risk. So 2Pay:

  • Gives every NPP payout a unique reference taken from the payment's own ID, and sends the same value as the idempotency key.
  • Uses the database row ID as the PayTo payment ID, so the same pull always carries the same ID.
  • Never retries a money call automatically. The only retry path is the sweep for stuck payments, which asks Monoova for the status first.
  • Claims each invoice for a pay run in one database step, backed by a unique index, so two jobs can't both take it.
  • Never pulls a cleared funding debit again.
  • Keeps an append-only record of every payout attempt. A new reference is only issued once the last attempt is known not to have run.

Monoova calls its unique reference a nonce and doesn't document what a duplicate response looks like, so 2Pay never sends a rejected reference again. Stripe's post on designing APIs with idempotency is still the clearest general explanation.

Where should the off switch for payments live?

At the point where money leaves, inside the code that calls the bank. The July audit found that 2Pay's only off setting stopped the automatic trigger, but the 15-minute sweep would have sent the same payouts anyway. A switch on one trigger isn't a kill switch.

Now the payout and payment calls check a database flag before any network call, and stop if payments are off. The flag flips without a redeploy. A freeze on one practice stops that practice without touching the others. Guards also run before anything in the database changes, so a blocked payment stays scheduled instead of landing in a confusing half state.

Every transfer is checked before it's sent, too. Zero, negative and unusually large amounts are refused locally. A PayTo ID that breaks Monoova's 35 character pattern fails straight away, not with a live error halfway through a pay run.

How do you secure payment webhooks?

Check two things on every webhook, and reject it if either fails. 2Pay verifies Monoova's RSA signature over the raw request body with Monoova's public key, then compares the security token registered with the subscription. A burst of rejected webhooks raises an alert.

The first version got this wrong. It checked a different signature header that Monoova doesn't send. In production, it would have rejected every real webhook. Worse, the tests checked for the same wrong header, so every build passed. The July audit caught it. Tests only prove the code does what you think the API does.

What Monoova's docs won't warn you about

ProblemWhat you seeFix
PayTo payment IDs are capped at 35 charactersA standard .NET GUID string is 36, so you get PAS_PMT_UID_INVALIDUse the 32 character GUID format without hyphens, and check the pattern before sending
A webhook field is singular in practice but plural in the schemaA "funds received $0.00" alert with no detailsAccept both names
IDs come back in upper caseWebhooks don't match the payment that started themCompare IDs without regard to case
Fetching a PayTo payment nests its fields one level downStatus is always empty, so stuck pulls never resolveRead both the nested and flat shapes
PayTo payer details are one or the otherSending a BSB and a PayID gives PAM_PAYER_ACCT_FORBIDDENSend exactly one
NPP account names are capped at 32 charactersA 400 error on long business namesTrim before sending
The submerchant ID is a 32 character Monoova ID, not your account number"sub-merchant ID is not valid" when creating an AutomatcherCheck its format when the app starts
Payments and PayTo use different hostsCalls go to the wrong APIKeep separate settings for each
PayTo isn't switched on in sandbox by defaultAll PayTo work is blockedAsk Monoova support on day one
Failed attempts are limited5 failed PayTo payments in 24 hours locks you out until the next day. 10 failed token requests block the account until the key is changedCache one token, and validate everything before sending
There's no "insufficient funds" code for payoutsYou can't tell "no money" from "rejected"Check the live available balance after a rejection

Monoova's PayTo status and error codes and webhook docs are the place to start. Keep the raw payload of every webhook you receive. When the docs and reality differ, the raw payload settles it.

How many transactions does a PayTo pay run cost?

One per doctor, plus one. PayTo starts single transfers, so each payer is its own debit, and the payout to the practice is already combined into one transfer. A practice with 10 doctors uses 11 transactions a run. For background on the move away from the older bulk direct debit system, BECS, see the Reserve Bank's March 2026 update.

How often you run is the real cost lever. For a practice with 10 doctors, moving from weekly to monthly pay runs cuts 572 transactions a year to 132, which is 77% fewer.

Getting production access with Monoova

What it took, in order:

  1. A sandbox account. The sandbox allows simulated payments up to $1,000, and a new Automatcher account can take up to five minutes to be ready for NPP.
  2. A request to Monoova support to switch on PayTo in sandbox. Do this first.
  3. An audit of every endpoint 2Pay calls against Monoova's OpenAPI specs, on 2 August 2026, plus a test screen that only exists in the development environment, so every call could be proved in sandbox. It shows the raw response beside the parsed one.
  4. Production built to start safely without live credentials, and to refuse to start if a URL points at sandbox or a secret is still a placeholder.
  5. Live credentials, in place by 6 September 2026, stored in AWS Secrets Manager for each environment.
  6. Webhook subscriptions registered in Monoova's portal for each environment, with the security token matching the stored value exactly, including the "Basic" prefix.
  7. The first production setup, on 6 October 2026.

The next step is Monoova's Ledger Accounts behind every practice's Automatcher, which gives each practice its own separate balance at Monoova. That's Monoova's recommended pattern, and I only learned it during the production setup. Ask your provider what it recommends before you design. One small irony: ledgers need the Monoova submerchant ID, which I'd removed as unused a month earlier.

What I'd tell anyone building on PayTo or NPP

  1. Read the spec, then test it against the real thing. Keep raw payloads and handle every shape you've seen.
  2. Unknown is its own state. Park the payment and ask the bank.
  3. Webhooks make things fast. Scheduled checks make them reliable.
  4. Put the off switch where the money leaves.
  5. Fail loudly when the app starts, not at 5 am on pay day.
  6. Model business-day rules, like next-day PayTo agreements, instead of fighting them.
  7. Settle the tax and legal shape of the money flow before you write code.
  8. Ship money features switched off. 2Pay's subscription billing went live with sending turned off until every sandbox check passed.
  9. Automated security scanners find real problems, and some of their suggested fixes would strand money. Read every patch before you merge it.

For your IT person

  • Stack: .NET 10, EF Core and PostgreSQL, and Hangfire with PostgreSQL storage running on Sydney time, hosted on AWS ECS Fargate.
  • Tests run against a real PostgreSQL database each run through Testcontainers for .NET, with Monoova faked by a routing HTTP handler, so money paths are tested against real queries and constraints.
  • Polly retries are switched off for Monoova money calls. A retried execute call could send twice.
  • The PayTo bearer token lasts 24 hours. It's refreshed an hour early, one fetch at a time, so a burst of calls can't burn through the failed authentication limit.
  • The webhook signing key is cached for 24 hours, and forced refreshes are rate limited so bad signatures can't trigger endless fetches. The token comparison runs in constant time.
  • Webhooks are de-duplicated on Monoova's webhook ID, with a body hash as a fallback. Raw payloads are stored, and support can replay one. Replaying an "agreement created" event after the agreement is active is refused, because it would disarm a live agreement.
  • Secrets are created by CloudFormation with GenerateSecretString as a random placeholder, and the app refuses to start until a real value replaces it. The task role can read only those secrets.
  • Logs mask third-party account numbers. BSBs and 2Pay's own Automatcher numbers are kept, because they are the key for reconciliation.
  • In the ledger design, Monoova is the source of truth for how much money there is, and 2Pay's own ledger is the source of truth for whose money it is. A difference between them raises a critical alert but doesn't block payouts.

About the 2Pay build

FactDetail
What it doesPays doctors in Australian medical practices
Built byOne engineer, Ryan Brooker at Hireadev
First commit25 March 2026
Backend tests5,131 passing on 7 October 2026
CodeAbout 80,100 lines of production C# and about 146,500 lines of test code, almost two lines of tests for every line of code
Stack.NET 10, EF Core and PostgreSQL, React 19, Auth0, AWS ECS Fargate in Sydney

More from the same build: Best Practice and Halo Connect integration: what it takes.

Hireadev is run by Ryan Brooker in Brisbane. If you need payments or other system integrations built with this kind of care, or an existing system checked for the problems above through software project rescue, book a free 30-minute chat. The 2Pay backend is built in .NET.

FAQ

No. PayTo starts single transfers, so each payer is its own debit. Plan your transaction costs per payer, not per batch.
With Monoova, from 00:00 Sydney time on the day after the agreement was created. The start date must be in the future, and pulls before it are refused.
Yes. Monoova's NPP status webhook only fires for payments that went through a Pending state, so payments that settle instantly need a status check.
Not with a new reference. Treat it as unknown, ask the provider for its status, then decide. A fresh reference on a payment that actually went through is how people get paid twice.
No. Patient and Medicare money goes straight into the practice's or the doctor's own bank account. 2Pay only pulls fees or net pay under a PayTo agreement the payer has approved.

Want a second opinion on your project?

Book a free 30-minute call with Ryan. Straight answers, no sales pitch.

Book a free call