Skill 61 · AWS Marketplace Metering
Subchapter 61.3
references/concurrent-agreements.mdMarkdown15 KBView on GitHub
This section is critical. Read it fully before generating any code.
AWS Marketplace introduced to allow buyers to make multiple purchases of the same product on a single AWS account. This fundamentally changes which fields are used for metering.
| Field | Old Integration (Legacy) | New Integration (Concurrent Agreements — required for new products) |
|---|---|---|
| Customer identity in UsageRecord | CustomerIdentifier (opaque string) | CustomerAWSAccountId (AWS account ID) |
| Product/agreement identity | ProductCode (at request level) | LicenseArn (per UsageRecord) |
| Deduplication scope | per (ProductCode + CustomerIdentifier + dimension + hour) | per (CustomerAWSAccountId + LicenseArn + dimension + hour) |
# ✅ CORRECT — New integration (Concurrent Agreements)
response = client.batch_meter_usage(
UsageRecords=[
{
'Timestamp': timestamp,
'CustomerAWSAccountId': '123456789012', # from ResolveCustomer
'LicenseArn': 'arn:aws:license-manager::123456789012:license/lic-xxx', # from ResolveCustomer
'Dimension': 'MyDimension',
'Quantity': 5,
}
]
# NOTE: No ProductCode at request level when using LicenseArn
)# ❌ WRONG — Using CustomerIdentifier for metering
response = client.batch_meter_usage(
UsageRecords=[
{
'Timestamp': timestamp,
'CustomerIdentifier': 'cust-example-id-123', # WRONG — don't use for new integrations
'Dimension': 'MyDimension',
'Quantity': 5,
}
],
ProductCode='abc123'
)
# ❌ WRONG — Mixing LicenseArn and ProductCode in the same request
response = client.batch_meter_usage(
UsageRecords=[
{
'Timestamp': timestamp,
'CustomerAWSAccountId': '123456789012',
'LicenseArn': 'arn:aws:license-manager::123456789012:license/lic-xxx',
'Dimension': 'MyDimension',
'Quantity': 5,
}
],
ProductCode='abc123' # WRONG — don't include ProductCode when using LicenseArn
)
# ❌ WRONG — Using CustomerIdentifier with LicenseArn
response = client.batch_meter_usage(
UsageRecords=[
{
'Timestamp': timestamp,
'CustomerIdentifier': 'cust-example-id-123', # WRONG — use CustomerAWSAccountId
'LicenseArn': 'arn:aws:license-manager::123456789012:license/lic-xxx',
'Dimension': 'MyDimension',
'Quantity': 5,
}
]
)ResolveCustomer returns LicenseArn, CustomerAWSAccountId, and ProductCode (plus a
legacy, now-deprecated CustomerIdentifier that is null for new integrations). Store the first
three, and use LicenseArn + CustomerAWSAccountId for metering:
result = client.resolve_customer(RegistrationToken=token)
# ResolveCustomer returns a TOP-LEVEL LicenseArn (plus CustomerAWSAccountId + ProductCode):
license_arn = result['LicenseArn'] # ✅ top-level field, e.g. arn:aws:license-manager::...
customer_aws_account_id = result['CustomerAWSAccountId'] # ✅ USE THIS (with LicenseArn) for metering
product_code = result['ProductCode'] # Store for validation only, NOT for metering
# result['CustomerIdentifier'] is DEPRECATED for new SaaS integrations and is null/absent —
# do NOT rely on it, and do NOT treat it as the LicenseArn. It is legacy-only (pre-CA).Key Fact — ResolveCustomer returns LicenseArn directly:
ResolveCustomerreturns a top-levelLicenseArnfield along withCustomerAWSAccountIdandProductCode. For NEW (Concurrent Agreements) integrations,CustomerIdentifieris deprecated and null/absent — do NOT use it and do NOT treat its value as the LicenseArn. PersistLicenseArn+CustomerAWSAccountIdfrom the ResolveCustomer response; both are also reaffirmed by theLicense UpdatedEventBridge event (a row can alternatively be created from that event when a seller never calls ResolveCustomer). Downstream (BatchMeterUsage, entitlement checks) requireLicenseArn+CustomerAWSAccountId.Key Fact: ResolveCustomer succeeding only means the registration token is valid — it does NOT confirm the subscription is active. Wait for the EventBridge
License Updated/ subscribe-succeed event before starting to meter.
Store subscribers in a SINGLE unified table (not separate customer-profiles + subscriptions tables). The primary key depends on the integration pattern:
| Pattern | Partition Key (PK) | Sort Key (SK) | Why |
|---|---|---|---|
| Concurrent Agreements (default, new products) | licenseArn | customerAWSAccountId | One buyer can hold MULTIPLE active licenses for the same product; keying on licenseArn tracks each agreement independently and prevents double-billing |
| Legacy (pre-CA, grandfathered) | customerAWSAccountId | — | One buyer = one subscription; no concurrent agreements |
Unified table attributes (PII-FREE): licenseArn (PK), customerAWSAccountId (SK), productCode, agreementId, agreementStatus (active / inactive — agreement lifecycle), subscriptionStatus (active / deprovisioning / inactive — license lifecycle; there is no deprovisioned value), and registeredRegions (a DynamoDB String Set (SS) of AWS Region names where the buyer was registered, added idempotently via ADD — reference only, never gates metering). It does NOT carry customerIdentifier for a new CA integration (deprecated, legacy-only) and does NOT carry buyer PII / registration-form data — that lives in the per-Region customer-profile table (keyed licenseArn PK + customerAWSAccountId SK, seller GSIs), written in-region by the register Lambda. Keeping the subscribers table PII-free is why it can safely live in us-east-1 even for opt-in-Region products. The subscribers table defines two GSIs so lookups never need a Scan: customerAWSAccountId-index (register lookup / all agreements for one buyer) and agreementId-index (subscription lookup when an event omits license.arn).
Critical: For CA products,
licenseArnMUST be the partition key — NOTcustomerAWSAccountIdand NOTlicenseArnas a sort key. A buyer with two concurrent agreements has two rows (samecustomerAWSAccountId, differentlicenseArn). Keying by account alone would collapse them and cause double-billing or lost usage. The usage table is likewise keyed bylicenseArn(PK) +customerAWSAccountId#dimension#timestamp(SK) so usage is attributed per-agreement. This unified single-table design aligns with the AWS Marketplace Serverless SaaS Integration reference architecture. Metering is driven by the usage table’smetering_pendingGSI (not by subscriber/registration state): the meter Lambda queries that GSI for licenseArns with pending usage, builds each UsageRecord entirely from the usage rows (which carrycustomerAWSAccountId), and only looks up the subscriber row to read/updatesubscriptionStatus. Usage rows are keyed by the reallicenseArn— there are nopending:-prefixed placeholder rows.
The table lives in us-east-1 alongside the EventBridge events (that is the only region where marketplace lifecycle events are emitted).
CustomerAWSAccountId + LicenseArn per UsageRecord. Do NOT include ProductCode at the request level.CustomerIdentifier + ProductCode, but should migrate to the new pattern.LicenseArn and ProductCode for the same customer in the same hour. This causes duplicate billing.CustomerIdentifier is NOT for metering: It is an opaque identifier for customer lookup. Use CustomerAWSAccountId in UsageRecords.LicenseArn is per-agreement: A single buyer can have multiple LicenseArn values for the same product. Each represents a separate agreement with separate usage tracking.rate(1 hour) schedule)TimestampOutOfBoundsException (and one bad timestamp rejects the whole batch). A 6-hour month-boundary grace period applies: previous-month records are accepted until 06:00 UTC on the 1st of the next month.Status = DuplicateRecord (NOT Success, and present in the response — not silently absent). The FIRST submitted quantity stays final/billed; a resubmission with a DIFFERENT quantity is NOT billed. If a seller intended to correct 5→10, the correction is not applied and they UNDER-BILL by 5. Warn sellers: an unexpected DuplicateRecord may mean under-reporting. Aggregate usage BEFORE submitting so you never need to correct.BatchMeterUsage call accepts max 25 UsageRecords AND the total request payload must be ≤ 1 MB. These are two INDEPENDENT limits — a batch of ≤25 records can still be rejected for SIZE if the records are large (many/large usageAllocations tag-sets, long ARNs). If you have 25 valid records but the request is rejected for size, split into smaller batches (fewer records per call) so each request stays under 1 MB — it is the byte size, not the record count, that is over. Rate limit: 10 BatchMeterUsage requests/second per account per region (documented quota) — the serial submitter stays within it.The shift to CustomerAWSAccountId + LicenseArn also dictates which notification mechanism you must use:
| Mechanism | Identifies customer by | Compatible with new integration? |
|---|---|---|
SNS (legacy aws-mp-subscription-notification-*) | CustomerIdentifier only | ❌ No — SNS notifications do not include CustomerAWSAccountId |
EventBridge (aws.agreement-marketplace) | CustomerAWSAccountId (via agreement/acceptor details) | ✅ Yes |
The problem: Legacy SNS subscription notifications (subscribe-success, unsubscribe-success, etc.) identify the customer using CustomerIdentifier. But since new integrations must key everything on CustomerAWSAccountId, you cannot reliably match an SNS notification to a customer record keyed by CustomerAWSAccountId without an extra lookup/mapping step.
EventBridge events (Purchase Agreement Created, Purchase Agreement Ended, License Deprovisioned, etc.) include the buyer’s AWS account ID in the event detail, making them directly compatible with CustomerAWSAccountId-based customer records.
Bottom line: If you are building a new integration, use EventBridge only. Do not set up SNS subscription topics. This skill deploys an EventBridge-based events stack in us-east-1 for this reason.
Note for existing products migrating: If you currently use SNS and are migrating to
CustomerAWSAccountId-based metering, you must also migrate your subscription event handling from SNS to EventBridge. Running both during migration is safe — just ensure your customer table can be updated by either path.
The path is chosen by the seller’s EXISTING integration, not by the ResolveCustomer response. ResolveCustomer returns a top-level LicenseArn for SaaS products regardless of whether the integration is old or new, so its response is NOT the signal for which path to use.
CustomerAWSAccountId + LicenseArn per UsageRecord.ProductCode + CustomerIdentifier can continue on that path. For an existing seller, the skill must understand the seller’s current integration/code and support or migrate it — it does not force this skill’s reference register.py / metering-pipeline handlers onto them.The reference metering pipeline (scripts/metering_core.py + scripts/submitter.py) defaults to the CA (LicenseArn) path. A ProductCode fallback branch exists only to support a seller whose stored records predate CA (identified by a non-ARN licenseArn value), NOT because ResolveCustomer returned no LicenseArn:
if str(customer.get('licenseArn', '')).startswith('arn:'):
record['LicenseArn'] = customer['licenseArn']
# No ProductCode at request level
else:
# Legacy stored record (pre-CA): use ProductCode + CustomerIdentifier
kwargs['ProductCode'] = PRODUCT_CODETwo independent status fields track different lifecycles and are NOT conflated:
agreementStatus (agreement) and subscriptionStatus (license). Metering decisions follow
the license lifecycle only:
Purchase Agreement Ended — set agreementStatus = 'inactive' ONLY. This is a
status update; it does NOT change subscriptionStatus, stop metering, or trigger a
flush. The buyer may still be entitled to usage until the license is deprovisioned.License Deprovisioned — set subscriptionStatus = 'deprovisioning' + a deprovisioningExpiry
(event time + ~1h). This OPENS the ~1-hour final-usage flush window: submit any remaining usage
IMMEDIATELY. The pipeline meters this row during the window; the events-stack deprovision-cleanup
Lambda (rate(15m)) then sets subscriptionStatus = 'inactive' once deprovisioningExpiry has
passed (time-based finalization — NOT the submitter). Once the server-side window closes,
BatchMeterUsage returns CustomerNotSubscribed (a terminal status — do not retry).Do not invert these.
License Deprovisioned(notPurchase Agreement Ended) opens the flush window. Do NOT stop metering or flush onPurchase Agreement Ended, and do NOT discard queued records before flushing (that loses revenue). There is nodeprovisionedstatus value — the license lifecycle isactive→deprovisioning→inactive.