v byte is 0/1 instead of 27/28, or your payload expired. Each has a specific symptom, listed under Why your signature is rejected.
Before you start
You need:- A proposal in status
PENDING_SIGNATURES, orPENDING_APPROVALSif you are capturing a co-signer early. - The private key for the address you registered as
userOwnerAddresswhen you created the smart wallet. - One of
ethers6,viem2, oreth-account0.13 (used byweb3.py).
What you are actually signing
GET /v1/users/{userId}/smart-wallets/proposals/{proposalId}/sign-payload hands you a digest that has already been constructed for you. You do not build the EIP-712 domain or types yourself. The backend prepares the structured data, hashes it, and gives you the resulting 32-byte digest in hashToSign.
That single fact removes most of the difficulty. Your job is to produce a raw secp256k1 signature over those 32 bytes — nothing more.
Sign hashToSign, not typedData
Exactly one of userOpHash or safeTxHash is set, and it tells you which hash hashToSign is:
- Relay-only proposal —
userOpHashis set.hashToSignis the EIP-712 digest of the ERC-4337 user operation. - Multi-signature proposal —
safeTxHashis set.hashToSignis the Safe transaction hash.
hashToSign. The distinction matters for understanding what you are authorising, not for the signing call.
typedData is populated only when the upstream includes the full EIP-712 object, and it is null otherwise. Treat it as a debugging aid for inspecting what the digest covers. Do not re-hash it and do not pass it to a signTypedData method — you will produce a different digest from the one the backend is expecting, and the signature will be rejected.Sign the digest
The rule in every language is the same: use the method that signs a raw hash, not the one that signs a message. A message-signing method applies the EIP-191 prefix\x19Ethereum Signed Message:\n32 and hashes your digest a second time.
All three snippets below produce a byte-identical signature. Each is verified against the test vector in Reproduce a known-good signature.
Submit the signature
requiredSignatures, the proposal is submitted on-chain.
Expected result
A200 response carrying the updated proposal. Its status stays PENDING_SIGNATURES while further signatures are outstanding, and becomes COMPLETED after on-chain execution settles.
Poll GET /v1/users/{userId}/smart-wallets/proposals/{proposalId} for the terminal status.
Reproduce a known-good signature
Run this before you debug your integration. It uses the well-known Anvil test account, so you can confirm your toolchain produces the exact bytes BMONI expects without touching a real key or a real proposal. The digest iskeccak256 of a fixed string, so you can regenerate every value here from scratch:
expected, the fault is in your signing code, not in your proposal. Work through the next section.
Why your signature is rejected
Point is not on curve
The bytes you sent are not a decodable secp256k1 signature. Check, in order:
- Length. A signature is exactly 65 bytes —
0xplus 130 hex characters. A 64-byte signature is missing itsvbyte; a 66-byte one usually has a stray0xin the middle from concatenatingr,s, andvas prefixed strings. - Encoding. Send hex, not base64, and not a byte array serialised as JSON.
- Component order. The layout is
r(32 bytes), thens(32 bytes), thenv(1 byte). Assemblingvfirst, orsbeforer, produces bytes that decode to a point off the curve.
Invalid yParityOrV
Your v byte is 0 or 1. Some libraries return the raw recovery bit as yParity; BMONI expects the Ethereum convention of 27 or 28.
Normalise the last byte:
27/28. You hit this when assembling r, s, and v by hand from a lower-level library.
500, or the signature is recorded but never executes
You almost certainly signed through a message-signing method. This is the most common failure and the hardest to spot, because the signature is structurally valid — it decodes cleanly and has a correct v byte. It simply recovers to a different address, so it never matches the proposal’s signer snapshot.
Using the test vector above, the two paths diverge like this:
Replace the call as follows:
Signature deadline exceeded
The deadline in the sign payload has passed. Fetch a fresh payload with GET …/sign-payload and sign again. Do not cache a payload across a user session — fetch it immediately before signing.
The recovered signer is not authorised
The signature is valid but the address is not on the proposal’s signer snapshot. The signing key must be the one registered asuserOwnerAddress at wallet creation. If you rotate keys in your own store between wallet creation and signing, the snapshot still holds the original address.
Validating a signature without moving money
There is currently no endpoint that validates a signature without submitting it. Until one exists, use the test vector on this page to prove your toolchain end to end, then run a minimum-value proposal in the sandbox as your first live exercise.A sandbox validate-only endpoint is tracked as platform work. When it ships, this section will describe it.
Related
- Transfers — creating and approving the proposal you sign here.
- Errors and status codes — every error this endpoint can return.
- SDK signing — signing through
bmoni_embedded_sdkinstead of a raw key. - Sandbox test data — values that resolve in the sandbox.
Last reviewed: 7 August 2026. Snippets verified against
ethers 6.17.0, viem 2.55.10, and eth-account 0.13.7.
