🔑 Push data with API keys
Send your data to Meza when your database cannot be reached from the internet.
When you need this
Most customers connect a database and Meza reads from it directly. That is simpler and the data is always current, so try that first.
Use API keys instead when Meza cannot reach your database:
- An AWS RDS instance in a private VPC with no public endpoint
- An Azure Database restricted to a virtual network
- An on-premise database behind a corporate firewall
- A security policy that forbids granting external read access to production
You query your own database and post the results here. Once the data arrives it behaves exactly like a connected database — health scores, account matching and everything else work the same way.
How it works
Create an API key
Go to Configurations → Integrations → Databases, open Push data with an API key and click New key. Name it after whatever will use it, like "Nightly ETL job".
Copy the key
The key is shown once and cannot be retrieved afterwards. Store it wherever your job keeps its secrets. If you lose it, revoke it and create another.
Post your records
Send your data as JSON to the ingestion endpoint, using the key as a Bearer token. One request per data type.
Check it arrived
Call the status endpoint to see what Meza holds, so you can reconcile against your own counts.
Why you might want more than one key
One key can send every kind of data, so a single key is fine to start with. Separate keys are useful when different systems send different things:
- One job per key. Your nightly account export and your activity stream can each have their own, so you can see which one last sent data.
- Rotate without downtime. Create a new key, switch your job over, then revoke the old one.
- Limit the damage. If one system is compromised, revoke that key alone and the rest keep working.
Sending data
Post to /api/data-models/ingest/<type>/ with your key in the Authorization header.
curl -X POST https://api.meza.ai/api/data-models/ingest/accounts/ \
-H "Authorization: Bearer mzi_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"records": [
{
"external_id": "cust_1042",
"name": "Northwind Trading",
"domain": "northwind.example",
"arr": 84000,
"status": "active"
},
{
"external_id": "cust_1043",
"name": "Beacon Logistics",
"domain": "beaconlog.example",
"arr": 32000,
"status": "active"
}
]
}'You get back what happened:
{
"status": "success",
"model_type": "accounts",
"created": 2,
"updated": 0,
"rejected": [],
"rejected_count": 0
}external_id — the one required field
Every record needs an external_id: your own identifier for that row, usually its primary key.
It is what makes sending safe to repeat. If your job times out halfway and runs again, records with the same external_id are updated rather than duplicated. Without it, every retry would double your data.
⚠️ Warning
external_id are rejected. The response tells you which ones and why, and the rest of the batch is still accepted.Field names
Send the field names below exactly. Meza reads these names, so if your database uses different ones, alias them in your query rather than renaming anything on your side:
SELECT id AS external_id,
company_name AS name,
website AS domain,
annual_value AS arr,
state AS status
FROM customers
WHERE deleted_at IS NULL💡 Note
What to send, and why
You can send any of the types below. The first three carry most of the weight in a health score — the rest add detail.
Accounts — required
| Field | Required | Why it matters |
|---|---|---|
| external_id | Yes | Your id for this customer. Everything else references it. |
| name | Yes | What the account is called throughout Meza |
| domain | Strongly recommended | How emails, meetings and messages get attached to this account. Without it, matching falls back to the name, which is fragile when several customers share a word. |
| arr | Recommended | Revenue at risk, and how accounts are prioritised |
| status | Recommended | active, churned, trial — excludes churned accounts from scoring |
| industry | Optional | Segmentation |
Users — required
| Field | Required | Why it matters |
|---|---|---|
| external_id | Yes | Your id for this person |
| account_id | Yes | The external_id of the account they belong to |
| Yes | Links this person to their emails, meetings and messages | |
| name | Recommended | Who a CSM sees on the account |
| last_login_at | Recommended | Feeds the recency part of engagement directly |
| role | Optional | Distinguishes a champion from an occasional user |
| is_active | Optional | Deactivated users are excluded from scoring |
Activities — this is what actually drives the score
| Field | Required | Why it matters |
|---|---|---|
| external_id | Yes | Your id for this event |
| account_id | Yes | Which customer this belongs to |
| created_at | Yes | When it happened, as ISO 8601 |
| user_id | Recommended | Which person did it — needed for per-user engagement |
| activity_type | Recommended | What they did, e.g. report_exported, invite_sent |
| feature_name | Optional | Which part of your product they used |
⚠️ Warning
created_at contributes nothing. Engagement is weighted 30% activity, 30% recency, 20% frequency and 20% depth — three of those four are calculated from timestamps.Send at least 90 days of activity history on your first push. Trends compare recent behaviour against earlier behaviour, so without history every account looks new rather than improving or declining.
Support tickets and subscriptions — strongly recommended
Tickets give the experience side of the score: volume, response times, and whether a customer is repeatedly stuck. Subscriptions give renewal dates and revenue at risk, which is what turns a low score into a prioritised action.
| Type | Fields that matter most |
|---|---|
| support_tickets | external_id, account_id, subject, status, priority, created_at, resolved_at |
| subscriptions | external_id, account_id, plan, arr, status, renewal_date |
Meetings, notes and conversations — optional
These enrich the picture rather than drive the score. Send them if you hold this data outside the tools Meza already connects to.
Send accounts first
Everything else references an account by its external_id. A user or activity pushed before its account exists is stored, but stays unattributed until you send it again.
Order your job like this:
Accounts
All of them, so every reference below resolves.
Users
With account_id set to the account's external_id.
Everything else
Activities, tickets and subscriptions, in any order.
# 1. The account
{ "external_id": "cust_1042", "name": "Northwind Trading",
"domain": "northwind.example", "arr": 84000 }
# 2. A user at that account
{ "external_id": "user_88", "account_id": "cust_1042",
"email": "ana@northwind.example", "name": "Ana Roy" }
# 3. An activity from that user
{ "external_id": "evt_9912", "account_id": "cust_1042",
"user_id": "user_88", "activity_type": "report_exported",
"created_at": "2026-08-13T09:14:00Z" }A minimum viable first push
If you want to see something working before building the full pipeline, this is the smallest useful set:
- Every account, with
name,domainandarr - Every active user, with
emailandaccount_id - 90 days of activities, with
account_idandcreated_at
That produces real health scores. Tickets and subscriptions can follow once the first three are flowing.
Batches and limits
| Limit | Value | What happens if you exceed it |
|---|---|---|
| Records per request | 1,000 | The request is rejected — split it into smaller batches |
| Requests | No fixed limit | Send at whatever pace suits your job |
| Record size | No fixed limit | Send the fields you have; extra fields are kept |
A batch where some records are valid and some are not returns 207, accepts the good ones, and lists the rejected ones with the reason and their position in your array. One bad row never costs you the other nine hundred.
Removing a record
When a row is deleted on your side, delete it here too. Your system is the record of truth.
curl -X DELETE \ https://api.meza.ai/api/data-models/ingest/accounts/cust_1042/ \ -H "Authorization: Bearer mzi_your_key_here"
This is also how you handle a customer's erasure request.
Checking what arrived
curl https://api.meza.ai/api/data-models/ingest/status/ \ -H "Authorization: Bearer mzi_your_key_here"
{
"status": "success",
"data": {
"organization": "Your Company",
"models": [
{ "model_type": "accounts", "records": 311,
"last_received": "2026-08-14T02:15:00Z" },
{ "model_type": "users", "records": 2894,
"last_received": "2026-08-14T02:16:12Z" }
]
}
}Compare these counts against your own. If they disagree, something in your job is not sending everything it should.
How often to send
As often as your data changes and your CSMs need it to be current. Nightly suits most customers. Hourly is better if your team acts on same-day signals.
💡 Note
A worked example: AWS RDS in a private VPC
A Lambda inside the same VPC can reach the database and the internet, which makes it a natural place to run this.
import json, os, urllib.request
import psycopg2
MEZA_KEY = os.environ['MEZA_INGESTION_KEY']
MEZA_URL = 'https://api.meza.ai/api/data-models/ingest'
def push(model_type, records):
"""Send in batches of 500, well under the 1,000 limit."""
for i in range(0, len(records), 500):
body = json.dumps({'records': records[i:i + 500]}).encode()
req = urllib.request.Request(
f'{MEZA_URL}/{model_type}/',
data=body,
headers={
'Authorization': f'Bearer {MEZA_KEY}',
'Content-Type': 'application/json',
},
)
with urllib.request.urlopen(req) as r:
print(model_type, json.loads(r.read()))
def handler(event, context):
conn = psycopg2.connect(os.environ['DATABASE_URL'])
cur = conn.cursor()
# Accounts first — everything else references them.
cur.execute("""
SELECT id, company_name, website, annual_value, state
FROM customers WHERE deleted_at IS NULL
""")
push('accounts', [
{'external_id': str(r[0]), 'name': r[1], 'domain': r[2],
'arr': float(r[3] or 0), 'status': r[4]}
for r in cur.fetchall()
])
# Then the people, linked by the account's external_id.
cur.execute("""
SELECT id, customer_id, email, full_name, last_login
FROM users WHERE is_active = true
""")
push('users', [
{'external_id': str(r[0]), 'account_id': str(r[1]), 'email': r[2],
'name': r[3], 'last_login_at': r[4].isoformat() if r[4] else None}
for r in cur.fetchall()
])
# Then ninety days of activity.
cur.execute("""
SELECT id, customer_id, user_id, action_type, created_at
FROM product_events
WHERE created_at > now() - interval '90 days'
""")
push('activities', [
{'external_id': str(r[0]), 'account_id': str(r[1]),
'user_id': str(r[2]) if r[2] else None,
'activity_type': r[3], 'created_at': r[4].isoformat()}
for r in cur.fetchall()
])
conn.close()
return {'statusCode': 200}Store the key in Secrets Manager rather than an environment variable in production, and schedule the Lambda with EventBridge at whatever cadence you chose above.
Azure
The same shape works with an Azure Function on a VNet-integrated plan, or a container in the same virtual network as the database. The only requirements are that it can reach your database privately and reach api.meza.ai over HTTPS.
If something goes wrong
| Response | What it means | What to do |
|---|---|---|
| 401 | The key is wrong, or has been revoked | Check the header reads "Bearer mzi_…" and that the key is still active |
| 400 | The body is not shaped correctly | Records must be an array under a "records" key |
| 207 | Some records were rejected | Read the rejected array — it gives the position and reason for each |
| 413 | Batch too large | Split into batches of 1,000 or fewer |
Keeping keys safe
- Store keys in a secrets manager, never in source control
- Use a separate key per system, so one can be revoked without affecting others
- Revoke immediately if a key is exposed — it stops working at once
- The panel shows when each key was last used and from which address, so an unfamiliar entry is worth investigating