What gets imported
Two files. contacts.csv has one row per customer: signup date, email, name, account and first-touch attribution. revenue.csv has one row per payment or refund. Import the contacts first: every revenue row needs its customer.
- Imported data appears at its true dates: signups per day, customers, paying customers, LTV, revenue over time and the Attribution report.
- Past behavioral events are not imported, and an import records none: no
$signup,$revenueor$identifyevents, no active-user days (DAU, WAU and MAU do not move), no campaign flow enrollment, no goal conversions and no operator notifications. - Importing the same files again changes nothing. Customers are matched by
userIdand payments bykindplusreference, also against the revenue your backend already sends live, so nothing is counted twice. - Only owners and admins of the organization can import. The importer lists every import with who ran it, when, the file and its counts.
File format
| Rule | Value |
|---|---|
| Encoding | UTF-8. A byte order mark (BOM) is accepted and ignored. A file that is not valid UTF-8 is refused. |
| Header | The first line names the columns. Names match ignoring case, spaces, _, - and ., so signed_up_at, SignedUpAt and signedUpAt are the same column. Order does not matter. Unknown columns are listed and ignored; a missing required column refuses the file. |
| Delimiter | Comma (recommended). A file delimited by semicolons or tabs is detected from its header line. |
| Quoting | RFC 4180: wrap a value in double quotes when it contains the delimiter, a double quote or a line break, and double every quote inside it ("Studio ""Norte"""). Use your language's CSV library instead of joining strings. |
| Line endings | LF or CRLF. Blank lines are skipped, and so are lines of only spaces and delimiters (a spreadsheet's empty row, ,,,). |
| Empty cell | No value. Spaces around a value are trimmed. |
| Optional columns | Only the required columns must be in the header. Leave an optional column out, or leave its cells empty. |
| Lengths | Limits count characters as JavaScript does (string.length, UTF-16 code units), after trimming. A value over its limit rejects the row (too_long), except in the attribution columns, which are cut to the limit. |
| Size | At least one row; at most 250,000 rows and 64 MiB per file. Skip a file that would hold only its header. Split a bigger export into several files and import them one after the other: each file is independent. |
| Row numbers | Errors name the line where the row starts; the header is line 1. |
A row with fewer values than the header, or with extra values that are not empty, is rejected as malformed_row (usually an unquoted comma or line break). Extra empty values, such as a trailing comma, are ignored.
contacts.csv
One row per customer. Required columns: userId and signedUpAt.
| Column | Required | Format | Notes |
|---|---|---|---|
userId | yes | Text, 1 to 255 characters, no control characters | Your product's stable user id: exactly the userId your backend sends to @ravenkeep/node. Once per file. |
signedUpAt | yes | Date-time with a zone, such as 2025-03-14T09:26:53Z | When the customer created their account. Drives signups per day and the Attribution cohort. |
email | no | An address with one @ and no spaces, up to 320 characters | The customer's address. Campaign flows may email it. A row whose address does not have that shape is rejected: leave the cell empty when an address is unusable. |
name | no | Text, up to 200 characters | Display name. |
accountId | no | Text, up to 255 characters | The customer's company, workspace or organization id in your product (the SDK's account.id). |
accountName | no | Text, up to 200 characters | The account's display name. Needs accountId. |
utmSource | no | Text, up to 255 characters (longer values are cut) | utm_source of the first visit, such as google. |
utmMedium | no | Text, up to 255 characters (longer values are cut) | utm_medium, such as cpc. |
utmCampaign | no | Text, up to 255 characters (longer values are cut) | utm_campaign. |
utmTerm | no | Text, up to 255 characters (longer values are cut) | utm_term. |
utmContent | no | Text, up to 255 characters (longer values are cut) | utm_content. |
gclid | no | Text, up to 512 characters (longer values are cut) | Google Ads click id. |
fbclid | no | Text, up to 512 characters (longer values are cut) | Meta click id. |
msclkid | no | Text, up to 512 characters (longer values are cut) | Microsoft Ads click id. |
referrer | no | Text, up to 2048 characters (longer values are cut) | The referring page of the first visit: an absolute http(s) URL, else dropped. |
landingPage | no | Text, up to 2048 characters (longer values are cut) | The first page opened: an absolute http(s) URL or a path starting with /, else dropped. |
attributionCapturedAt | no | Date-time with a zone | When the first touch happened. Defaults to signedUpAt; ignored when every attribution column is empty. |
Fill the attribution columns only with what you stored when the customer signed up (the first touch: the UTM parameters, click ids, referrer and landing page of their first visit). Values longer than the limit are cut. Leave every attribution column empty when you never stored it; never guess.
revenue.csv
One row per payment or refund that actually moved money. Required columns: userId, amount, currency, reference and occurredAt.
| Column | Required | Format | Notes |
|---|---|---|---|
userId | yes | Text, 1 to 255 characters | The paying customer: a userId of contacts.csv or one Ravenkeep already knows from the SDK. |
amount | yes | Integer in minor units (cents), 0 to 1000000000000 | 1999 is 19.99. Digits only: no decimal point, sign, symbol or separator. Always positive, refunds too. |
currency | yes | ISO 4217 code, such as USD | Must be the project's currency. |
kind | no | payment or refund | Defaults to payment. A refund subtracts its amount from the customer's LTV. |
reference | yes | Text, 1 to 255 characters, no control characters | The stable id of the payment in your billing system (Stripe in_..., ch_... or re_...). With kind, the idempotency key. |
occurredAt | yes | Date-time with a zone | When the money moved (the invoice's paid time, the refund's creation time). |
- Export only payments that succeeded: paid invoices, captured charges. Skip drafts, open, void and uncollectible invoices and failed charges.
- Invoices of zero can be left out: a customer becomes paying only with a payment above zero.
- One row per refund, with the refunded amount (positive) and
kindrefund. A partial refund is a refund of the partial amount. - Revenue belongs to a person: map an organization's invoices to the user who pays for it (its owner or billing contact), and include that user in
contacts.csv. - For a payment your backend already sent live with
revenue(), use the samereference,amountand customer it sent. A different amount or customer is reported asreference_conflict, the live record is kept and nothing is counted twice.
Dates and times
signedUpAt, occurredAt and attributionCapturedAt are instants with a time zone. Write them in UTC with a Z.
| Value | Accepted |
|---|---|
2025-03-14T09:26:53Z | Yes (recommended). |
2025-03-14T09:26:53.123Z | Yes. Fractions are kept to the millisecond. |
2025-03-14T06:26:53-03:00 | Yes, stored as 2025-03-14T09:26:53Z. |
2025-03-14 09:26:53.123456+00 | Yes (the PostgreSQL text form of timestamptz). |
2025-03-14 | No: no time and no zone. |
2025-03-14 09:26:53 | No: no zone. If the source column holds UTC (a timestamp without time zone or a MySQL DATETIME written in UTC), append Z. |
1741944413 | No: convert epoch seconds (Stripe timestamps) to ISO 8601 first. |
Dates must fall between the years 1900 and 9999 and be at most 5 minutes after the import's clock. Daily charts group by UTC day.
Amounts and currency
amount is an integer in the currency's minor unit (cents), the unit Stripe and revenue() of @ravenkeep/node use. Digits only: no decimal point, sign, currency symbol or thousands separator. Nothing is rounded: a value with decimals is rejected.
| Price | `currency` | `amount` |
|---|---|---|
| 19.99 US dollars | USD | 1999 |
| R$ 49,90 | BRL | 4990 |
| 500 yen (no minor unit) | JPY | 500 |
| 1.250 Kuwaiti dinars (three decimals) | KWD | 1250 |
currency must be the project's currency (shown in the importer). A row in another currency is rejected with currency_mismatch: Ravenkeep does not convert. Set the project currency before the first revenue import, because it cannot change once the project has revenue.
A payment adds its amount to the customer's lifetime value (LTV) and a refund subtracts it. A customer becomes paying at their earliest payment above zero.
Idempotency and merge rules
Import as often as you like: an import never creates a second customer and never counts a payment twice. Run the same files again after fixing rejected rows; finished rows come back as unchanged.
| Record | Key | Rule |
|---|---|---|
| Customer | userId | One customer per project and userId. A userId may appear once per contacts.csv; later rows with it are rejected as duplicate_user_id. |
| Payment or refund | kind + reference | Already recorded with the same customer, amount and currency (by an earlier import, or live by revenue()): unchanged. With another customer, amount or currency: rejected as reference_conflict, and the stored record is kept. occurredAt is not compared. |
When the customer already exists
A customer your live integration already sent, or an earlier import created, is completed, never overwritten:
| Field | Rule |
|---|---|
signedUpAt | The earliest of the stored and the imported date wins. |
email | Written only when the customer has no email. Imported addresses are marked as imported; campaign flows may email them, and a later call from your backend replaces them. An address that unsubscribed in the project (in any letter case, on any customer) stays unsubscribed: campaign flows skip it. |
name | Written only when empty. |
accountId, accountName | Written only when the customer has no account. When the stored account has the same id and no name, the name is filled. |
| Attribution columns | Written only when the customer has no attribution yet. The first touch is never replaced. |
| First and last seen | A new customer is first and last seen at its signup date. For an existing one, first seen moves back to the signup date when it was later and last seen never moves: an import is not activity. |
| Lifetime value | Changed only by new rows of revenue.csv. |
An imported customer counts as signed up: a later signup() from your backend for the same userId does not enroll them in signup flows again. Live events after the import trigger flows as usual.
Mapping a typical SaaS database
Read from a replica or with a read-only user: the export never writes to the source. The usual sources:
| Column | Typical source |
|---|---|
userId | users.id, the same id your backend sends to the SDK. |
email, name | users.email, users.name (or first and last name joined with a space). |
signedUpAt | users.created_at. |
accountId, accountName | The user's organization, workspace or team (organizations.id, organizations.name). When a user belongs to several, pick one deterministically: the one the user last used, else the earliest membership, ties broken by id. |
| Attribution columns | The UTM columns stored at signup, if any. Otherwise leave them empty. |
revenue userId | The customer your live integration credits for that payment: usually the organization's owner (earliest owner membership) or billing contact. If the payer changed after a payment was sent live, the import reports it as reference_conflict and keeps the live record. |
amount | The amount your backend sends to revenue() for that payment (Stripe invoice total or amount_paid, whichever it uses); charge amount_captured; refund amount. |
currency | The invoice, charge or refund currency, uppercased. |
reference | Stripe invoice id in_... (charge id ch_... without invoices); refund id re_.... |
occurredAt | Invoice status_transitions.paid_at, charge created, refund created (epoch seconds: convert to ISO 8601 UTC). |
- Leave out internal, staff and test accounts, and payments of other products that share the billing account (filter them by their metadata).
- Keep inactive users that were real customers: their history is part of your signups and revenue. If your product deletes users for good, their history is gone: export what remains (their organization's payments go to its current payer).
- Leaving someone out of the export does not remove them from Ravenkeep: customers your live integration already sent stay.
- Sort the output (contacts by
signedUpAtthenuserId, revenue byoccurredAtthenreference) so two runs write the same files.
-- psql: \copy (<this query on one line>) TO 'contacts.csv' WITH (FORMAT csv, HEADER)
SELECT u.id AS "userId",
u.email AS "email",
u.name AS "name",
to_char(u.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "signedUpAt",
o.id AS "accountId",
o.name AS "accountName"
FROM users u
LEFT JOIN LATERAL (
SELECT org.id, org.name
FROM memberships m JOIN organizations org ON org.id = m.organization_id
WHERE m.user_id = u.id
-- the organization the user last used, else the earliest membership
ORDER BY (m.organization_id = u.last_organization_id) DESC, m.created_at, org.id
LIMIT 1
) o ON true
ORDER BY u.created_at, u.id;SELECT payer.user_id AS "userId",
i.total AS "amount", -- the amount your live revenue() call sends
upper(i.currency) AS "currency",
'payment' AS "kind",
i.stripe_invoice_id AS "reference",
to_char(i.paid_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "occurredAt"
FROM invoices i
JOIN LATERAL (
-- the organization's earliest owner pays
SELECT m.user_id FROM memberships m
WHERE m.organization_id = i.organization_id AND m.role = 'owner'
ORDER BY m.created_at, m.user_id
LIMIT 1
) payer ON true
WHERE i.status = 'paid' AND i.total > 0
ORDER BY i.paid_at, i.stripe_invoice_id;Example files
userId,email,name,signedUpAt,accountId,accountName,utmSource,utmMedium,utmCampaign
u_1001,ana@example.com,Ana Souza,2024-11-02T14:05:31Z,org_77,Acme Ltda,google,cpc,brand
u_1002,bruno@example.com,"Bruno, Jr.",2025-01-15T09:00:00Z,org_77,Acme Ltda,,,
u_1003,,Carla Dias,2025-02-20T22:47:10.250Z,org_81,"Studio ""Norte""",newsletter,email,feb-launchuserId,amount,currency,kind,reference,occurredAt
u_1001,1999,USD,payment,in_1QaB2cD3eF4gH5iJ,2024-11-02T14:07:02Z
u_1001,1999,USD,payment,in_1QkL6mN7oP8qR9sT,2024-12-02T14:07:11Z
u_1003,4990,USD,payment,in_1QuV0wX1yZ2aB3cD,2025-02-20T22:50:00Z
u_1003,4990,USD,refund,re_3QeF4gH5iJ6kL7mN,2025-02-27T10:12:45ZRow errors
The dry run and the import report every rejected row with its line, code, column and message, and the console downloads them as a CSV to fix. The other rows of the file are imported.
| Code | Meaning | Fix |
|---|---|---|
required | A required value is empty. | Fill the column for that row, or leave the row out. |
invalid_format | A value has the wrong shape: a date without a zone, an amount with decimals, an invalid email. | Write the value in the format of the column table. |
too_long | A value is longer than the column allows. | Shorten it. |
out_of_range | A number or date is outside the accepted range (a future date, an amount above the cap, a negative amount). | Check the source value; refunds are positive amounts with kind refund. |
invalid_value | kind is not payment or refund. | Use one of the two values. |
malformed_row | The row has fewer values than the header, or extra non-empty values. | Quote values that contain the delimiter or a line break. |
duplicate_user_id | The userId already appeared earlier in contacts.csv; the first valid row wins. | Export one row per customer. |
currency_mismatch | The currency is not the project's currency. | Import only revenue in the project currency; Ravenkeep does not convert. |
unknown_contact | No customer with this userId exists in the project. | Add the customer to contacts.csv and import it first, or fix the payer id. |
reference_conflict | This kind and reference is already recorded with another customer, amount or currency. | Check which record is right; references must identify one payment. |
Running the import
- Open the importer: Settings > Projects > your project > Import (owners and admins).
- Add the files:
contacts.csvand, optionally,revenue.csv. The console checks the columns and every row in the browser. - Dry run: counts the rows that would be created, updated, unchanged or rejected. Nothing is written.
- Import: rows go in batches of up to 1,000 rows (and about 1 MiB), contacts before revenue. Keep the tab open. If it stops (a closed tab, a network failure), import the same file again: finished rows come back unchanged.
- Fix rejected rows: download them, correct them and import that file.
Checklist for an export script
- Read only: never write to the source database or billing account.
- Write two UTF-8 files with a header row and the column names of this page, comma delimited, quoted by a CSV library.
userIdandreferenceare the values your live integration sends.- Date-times in UTC with
Z; amounts as integers in minor units;currencyuppercase and equal to the project currency. - One row per
userIdincontacts.csv; one row per payment or refund inrevenue.csv; every revenueuserIdpresent incontacts.csv(or already known to Ravenkeep). - Leave out internal and test accounts; leave attribution columns empty unless you stored them.
- Sort the rows so the output is deterministic.
- The files hold personal data: write them outside version control and delete them after the import.
- Print what was written and what was skipped (and why), so a person can compare it with the dry run.