M
Meza AI

🔑 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

1

Create an API key

Go to ConfigurationsIntegrations Databases, open Push data with an API key and click New key. Name it after whatever will use it, like "Nightly ETL job".

2

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.

3

Post your records

Send your data as JSON to the ingestion endpoint, using the key as a Bearer token. One request per data type.

4

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

Records without an 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

Fields we don't recognise are stored and kept, so sending extra columns costs nothing. Only the names below are read.

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

FieldRequiredWhy it matters
external_idYesYour id for this customer. Everything else references it.
nameYesWhat the account is called throughout Meza
domainStrongly recommendedHow 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.
arrRecommendedRevenue at risk, and how accounts are prioritised
statusRecommendedactive, churned, trial — excludes churned accounts from scoring
industryOptionalSegmentation

Users — required

FieldRequiredWhy it matters
external_idYesYour id for this person
account_idYesThe external_id of the account they belong to
emailYesLinks this person to their emails, meetings and messages
nameRecommendedWho a CSM sees on the account
last_login_atRecommendedFeeds the recency part of engagement directly
roleOptionalDistinguishes a champion from an occasional user
is_activeOptionalDeactivated users are excluded from scoring

Activities — this is what actually drives the score

FieldRequiredWhy it matters
external_idYesYour id for this event
account_idYesWhich customer this belongs to
created_atYesWhen it happened, as ISO 8601
user_idRecommendedWhich person did it — needed for per-user engagement
activity_typeRecommendedWhat they did, e.g. report_exported, invite_sent
feature_nameOptionalWhich part of your product they used

⚠️ Warning

An activity without 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.

TypeFields that matter most
support_ticketsexternal_id, account_id, subject, status, priority, created_at, resolved_at
subscriptionsexternal_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:

1

Accounts

All of them, so every reference below resolves.

2

Users

With account_id set to the account's external_id.

3

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, domain and arr
  • Every active user, with email and account_id
  • 90 days of activities, with account_id and created_at

That produces real health scores. Tickets and subscriptions can follow once the first three are flowing.

Batches and limits

LimitValueWhat happens if you exceed it
Records per request1,000The request is rejected — split it into smaller batches
RequestsNo fixed limitSend at whatever pace suits your job
Record sizeNo fixed limitSend 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

Pushed data is only as fresh as your last send. A connected database is read live, so if yours becomes reachable later, connecting it gives you current data with nothing to maintain.

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

ResponseWhat it meansWhat to do
401The key is wrong, or has been revokedCheck the header reads "Bearer mzi_…" and that the key is still active
400The body is not shaped correctlyRecords must be an array under a "records" key
207Some records were rejectedRead the rejected array — it gives the position and reason for each
413Batch too largeSplit 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