Contracts: agreements and their evidence
Preview. Built, but not yet checked against a real provider account or a live deployment; it may still change.
A seller (a tenant) can put agreements in front of its own customers: a distance sales contract, a pre-information form, terms of service and a privacy notice. Payrudder keeps the texts, shows them where the customer pays, records that a specific text was accepted, and mails the customer a copy. It supplies templates and evidence, not legal guarantees: nothing here says that a text is sufficient in any country, and the starter texts must be reviewed by a lawyer before use (the plan lists what has and has not been checked).
The model
- A version is one edition of an agreement in one or more languages (
en,tr,de,fr,es,ar). It is immutable: editing a text publishes the next version of its series (a kind and an audience,consumerorbusiness). A version has asha256, an author and a time, and is material (customers who accepted an earlier one are asked again) or minor (they are not). The first version of a series is always material. - A text is restricted markdown:
#headings,-bullets,1.numbered items,**bold**, blank lines between paragraphs. Raw HTML is shown as text.{{variable}}placeholders come from a fixed list (seller_name,seller_address,seller_tax_id,seller_tax_office,seller_mersis,customer_name,customer_email,customer_address,order_description,order_total,order_currency,date); a text with any other placeholder is refused. Values are escaped when they are printed. - An acceptance is append-only evidence (in the database, by grants and triggers that also refuse
UPDATE,DELETEand, since the security review of 2026-11-02,TRUNCATEfor the table's owner; a sandbox tenant's rows and those of a tenant that has a cell may be deleted, for a reset and for a move; the database owner and a superuser can still switch a trigger off, and there is no hash chain: see the audit log): for each version accepted, the version and its hash, the hash of the text served and the language it was served in (and the one that was asked for), the words next to the box, the context (a hosted invoice, or the seller's own checkout), the variables as they were resolved, the time, and the IP address and user agent.observedsays which of these Payrudder saw itself and which the seller reported. The versions accepted together share agroup_idand one snapshot: the page they accepted, stored as it was written, with its sha256. The snapshot holds no IP address and no user agent. A PDF copy of it is drawn from the stored page on request (never stored): mailed to the customer with the acceptance, offered on the portal and in the console; a text the PDF font cannot draw (Arabic) has none. - Two consents of their own. A seller that starts a digital service at once (
immediate_start) asks a consumer for the request to start now and the acknowledgement of losing the right of withdrawal as two separate boxes, never merged with the acceptance of the contract. Each is recorded with its words. - A withdrawal request records a customer's statement (when it reached the seller, who decided, the outcome). The seller decides it; Payrudder never refunds from the statement. When the seller accepts a withdrawal that names an invoice and the setting
withdrawal_refundisfull(the default), billing is asked for a credit note that returns the invoice to the card (POST /v1/contract-withdrawals/{id}/refundasks later, or again after a failure;noneleaves the refund to the seller). The withdrawal says what became of it:refund_staterequestedwith thecredit_note_id, thenrefundedorrefund_failedonce the provider answered (copied from the credit note's owncredit_note.refunded/credit_note.refund_failed, which billing hands to plans-svc as tenant events; a repeated or reordered event changes nothing,refundedis final, and the withdrawal'sstatusstays the seller's to set),outsidefor a payment that cannot go back through Payrudder (a bank transfer: refund it by hand),failed(billing refused or did not answer: ask again). Arefund_failedwithdrawal is not asked for again: its credit note exists, so the card refund is retried on the credit note (refund_notesays why it failed), never by a second credit note. The refund is asked for once per withdrawal. The refund can be for less than what is left of the invoice (refund_amount_minorwhen accepting,amount_minorwhen asking again), and a withdrawal recorded throughPOST /v1/customers/{id}/contract-withdrawalsnames its invoice withinvoice_id.contract.withdrawal_decidedis sent for every decision. Key permissions: recording a withdrawal and deciding it needcustomers:write, so a checkout's server can do both; the refund is a credit note that returns money to a card, soPOST /v1/contract-withdrawals/{id}/refundneedsinvoices:write, and so does accepting a withdrawal whilewithdrawal_refundisfull(a key withcustomers:writeonly gets 403gateway.auth.permission_deniedand nothing is decided).
A business customer (customer_type = business) gets the business series when the seller published one, else the consumer text. A customer of unknown type is treated as a consumer (the consumer's text gives rights a business does not have, never the other way round).
Where it applies
PUT /v1/contracts/settings sets required_kinds. While it is not empty:
| Path | What happens |
|---|---|
POST /v1/subscriptions |
refused with plans.contract.not_accepted until the customer has accepted the current version of each required kind (record it with POST /v1/customers/{id}/contract-acceptances; GET .../contract-requirements returns the texts to show) |
Hosted invoice page, payment link and portal invoice (POST /i/{token}/pay, POST /c/{token}/invoices/{id}/pay) |
the page shows each text the customer has not accepted with an unticked box and a button that says it is an order with an obligation to pay; the server checks the boxes (the page needs no script) and starts nothing without them, or for a text replaced since the page was shown |
| Credit pack purchase, ad-hoc payment link | they end on the same invoice page, so they are covered there; the link's "continue" step takes no money |
A payment link that names its own agreements (required_contracts on POST /v1/payment-links) |
default (not agreed): the link's list replaces the seller's required_kinds for the page of that link (it does not add to it), and an empty or absent list means the setting applies. The kinds must be published when the link is made (plans.contract.kind_unpublished) and each may be named once (billing.payment_link.bad_required_contracts). The invoice link an ad-hoc link hands out when the payer continues carries the list; the same invoice opened by another link or by the portal asks for the setting |
| Renewals, dunning charges | never held back: nobody is present to accept |
A guest payment (a payment link with no customer) is covered by the invoice it was accepted for.
GET /v1/customers/{id}/contract-acceptances is paged (page_size, default 100, at most 200; next_page_token) newest first and can be narrowed by kind, source (api or hosted), version_id and the time of acceptance (from inclusive, to exclusive, RFC 3339).
The hosted pages fail closed: if the seller's agreements cannot be read, or a kind it requires has no published version, the page offers no way to pay and a pay request is refused.
After the acceptance
The customer is mailed the page they accepted, in their language (the template contract_accepted, which is a system mail the seller's mail switch does not hold back; a guest has no address and gets none). The mail is sent only when the page fetched has the hash the acceptance holds. The sellers' webhook endpoints get contract.accepted (one event for the group), contract.version_published and contract.withdrawal_requested (webhooks).
The portal, the withdrawal function and the renewal notice
- The portal (
/c/{token}) lists what the customer accepted, each with the page they accepted exactly as it was stored, and asks them to accept again when a material version was published since (a customer who accepted something before; nobody is asked on a first visit). Nothing is held back: renewals still charge. - "Withdraw from contract here" is a link on the page of a paid invoice and on the portal, offered to a consumer of a seller that requires agreements (switch it off with
withdrawal_function). The customer confirms their name and the contract, checks what will be sent, presses "Confirm withdrawal" and is mailed the acknowledgement with what they submitted and when. It records the statement (sourcehosted,contract.withdrawal_requested); the seller decides. Once per invoice, in the database: a unique index (plans-svc migration 00023, tenant 00016) allows one open (not rejected) hosted withdrawal for an invoice, so two presses of the button that race cannot both be recorded or acknowledged; the one that loses is told it was received already. A rejected withdrawal lets the customer try again. What a seller records through the API or the console is not limited by it (default, not agreed). What the customer types (their name, at most 200 characters, and their own words, at most 1000, 2000 bytes in the whole statement) is recorded as one line each: line breaks, tabs, other control characters and the bidirectional overrides are replaced or dropped, so a typed line cannot pass for a line of the statement ("Your name: Someone Else"); the acknowledgement mail and the pages print it as text, never as markup. Directive 2011/83/EU art. 11a, applied from 19 June 2026; which Member State's wording of the labels is enough is not checked. - The renewal notice is mailed once per renewal,
renewal_notice_daysbefore it (7 by default, 0 off), to a customer who accepted an agreement, with the price of the next period, the date and the seller's own words on how to cancel (renewal_cancel_texts, per language). Only subscriptions whose fee is invoiced in advance have such a date.
Keeping the evidence
retention_months (12 to 120, default 36) is how long the IP address and user agent of an acceptance are kept; a daily job blanks them afterwards and changes nothing else (it is the only thing that ever changes an acceptance, and the database allows it nothing more). The rest of the record, including the snapshot, stays. A sandbox reset deletes the sandbox's acceptances (they name its customers); live evidence is never deleted. Promoting a sandbox to live copies the agreement texts as live's own first versions and never an acceptance.
What was checked, and what was not
See plan 21: the Turkish Mesafeli Sözleşmeler Yönetmeliği (consolidated text of mevzuat.gov.tr) and the EU Consumer Rights Directive 2011/83/EU with Directive (EU) 2023/2673 (Publications Office) were read, with article numbers and dates, for the structure of the pages and the starter texts. Not checked: that any text is legally sufficient anywhere, whether Payrudder is an intermediary service provider under the Turkish rules, national transpositions of the EU texts, how long an IP address may be kept under KVKK or the GDPR, auto-renewal rules, and the wording of the box sentences in German, French, Spanish and Arabic.