# Metering integration guide

For engineering teams reporting metered usage through the WeTransact external API and reconciling it against Microsoft's records. It covers the identifiers you join on, how to report usage so that retries are safe, every verdict Microsoft can give, how to read back usage and revenue, and how to use the sandbox.

## Environments

| | Sandbox | Production |
|---|---|---|
| Base URL | `https://wetx-api-sandbox.azurewebsites.net` | `https://<yoursubdomain>.wetransact.io` |
| Authentication | `x-api-key: <sandbox key from WeTransact>` | `x-api-key: <key generated under Settings>` |
| Microsoft | Faked, stateful, fault-injectable | Real. Every accepted charge lands on your customer's Azure bill |
| Data | Two seeded subscriptions, seven days of usage history | Your tenant's subscriptions and reports |

A Postman collection with every request in this guide, including the sandbox fault scenarios, is available from WeTransact (`External-API-Metering.postman_collection.json` plus a sandbox environment file). Every request needs the `x-api-key` header; a missing key returns `401`.

**Wire format.** Responses are JSON with PascalCase property names (`MarketplaceSubscriptionId`, `Success`, `Microsoft.Status`). Error bodies returned before a usage report exists use lower-case `message` and `retryable`.

## Identifiers

| You have | It is | Where it appears |
|---|---|---|
| SaaS subscription id | The `{subscriptionId}` in every subscription URL | `GET /subscriptions` → `MarketplaceSubscriptionId`; `/financials` → `AssetId`; `/usage` → `ReferenceId` |
| Partner Center dimension id (for example `transaction_tier_1`) | The `dimensionId` you charge with | `GET /subscriptions?includeMeters=true` → `MeteringDetails.Meters[].MeterId` and `PartnerCenterMeterId` (same value); `/usage` → `MeterId` |
| Customer's Azure subscription | Microsoft's own `MarketplaceSubscriptionId` column in the usage report | `/usage` → `MarketplaceSubscriptionId`. Not the SaaS subscription id |
| Purchase line | One line of one purchase | `/financials` → `PurchaseRecordId` + `LineItemId`. Two orders can share one `AssetId` |

Start every integration with `GET /api/v1.0/subscriptions?includeMeters=true`. It lists only subscriptions whose plan has metered dimensions, with the dimension ids to charge and a `charge` link per subscription.

## Reporting usage

```http
POST /api/v1.0/subscriptions/{subscriptionId}/actions/charge
     ?dimensionId=transaction_tier_1
     &quantity=5
     &effectiveStartTime=2026-09-07T10:00:00Z
x-api-key: <key>
```

- `quantity`: whole number, at least 1.
- `effectiveStartTime`: optional, ISO 8601 with a `Z` or offset. Defaults to now. Must not be in the future or older than 24 hours. **Always send it explicitly** and store the value you sent: it is your idempotency key. If you omit it, the accepted response carries a `Warning` explaining why that is unsafe.

Microsoft accepts one usage event per subscription, dimension and hour of `effectiveStartTime`. WeTransact applies the same rule before calling Microsoft, but only against reports Microsoft has accepted, so a failed attempt never blocks its own retry.

### Response

```json
{
  "Timestamp": "2026-09-07T10:42:11Z",
  "MeterId": "transaction_tier_1",
  "MeterName": "Transactions tier 1",
  "Unit": "transaction",
  "Quantity": 5,
  "Success": true,
  "Message": "Success",
  "UsageReportEventId": "2b7f0c3a-5e1d-4a9b-9c2e-7d8f1a2b3c4d",
  "EffectiveStartTime": "2026-09-07T10:00:00Z",
  "Retryable": false,
  "Microsoft": {
    "StatusCode": 200,
    "Status": "Accepted",
    "UsageEventId": "6f8b3c1e-9a2d-4b7e-8c1f-2d3e4f5a6b7c",
    "Message": null
  }
}
```

`UsageReportEventId` is WeTransact's record of the report; `Microsoft.UsageEventId` is Microsoft's. Keep both.

### Status codes

| HTTP | Meaning | Body | Retry? |
|---|---|---|---|
| 200 | Microsoft accepted the usage event | Full response, `Success: true` | No |
| 400 | Invalid `quantity`, `dimensionId` or `effectiveStartTime` (unparsable, future, older than 24h) | `message`, `retryable: false` | Fix first |
| 404 | Subscription or dimension unknown | `message`, `retryable: false` | Fix first |
| 409 | Already reported for this hour. Either WeTransact accepted it earlier, or Microsoft answered Duplicate | Full response with the **original** `UsageReportEventId` and `Microsoft.UsageEventId`; `Microsoft.StatusCode` is `null` when Microsoft was not called | No |
| 422 | Microsoft rejected the event | Full response, see `Microsoft.Status` | Fix first |
| 429 | Too many concurrent charges (10 in flight, 5 queued) | Empty | Yes, same `effectiveStartTime` |
| 502 | Microsoft unreachable, timed out or answered 5xx. **Outcome unknown** | Full response, `Retryable: true` | Yes, same `effectiveStartTime` |

### The retry recipe

```text
send charge(subscription, dimension, quantity, effectiveStartTime)
  200 → done; store UsageReportEventId and Microsoft.UsageEventId
  409 → done; the hour had already landed, the body tells you which report
  429, 502, timeout → wait (backoff), send the identical request again
  400, 404, 422 → do not retry unchanged; fix the request or the subscription state
```

Resending the identical request can never double charge: if the first attempt landed at Microsoft, the retry gets 409 with its ids. Never change `effectiveStartTime` on a retry; that makes it a new usage event.

## Microsoft verdicts

Microsoft's own answer is relayed unchanged under `Microsoft`. Any Microsoft 4xx is returned as 422; 5xx as 502.

| `Microsoft.Status` | Microsoft HTTP | Returned as | Means |
|---|---|---|---|
| `Accepted` | 200 | 200 | Usage recorded |
| `Duplicate` | 409 | 409 | Microsoft already had this subscription, dimension and hour |
| `ResourceNotFound` | 400 | 422 | Microsoft does not know the subscription |
| `ResourceNotAuthorized` | 403 | 422 | The publisher is not allowed to report for this resource |
| `ResourceNotActive` | 400 | 422 | Subscription suspended or not yet activated |
| `InvalidDimension` | 400 | 422 | Dimension not in the subscription's plan |
| `InvalidQuantity` | 400 | 422 | Quantity must be greater than zero |
| `BadArgument` | 400 | 422 | Malformed request |
| `Expired` | 400 | 422 | `effectiveStartTime` older than 24 hours at Microsoft |
| `InternalServerError` and other 5xx | 5xx | 502 | Retry with the same `effectiveStartTime` |

## Reading back what Microsoft recorded

### Usage per dimension per day

```http
GET /api/v1.0/usage?subscriptionId=<SaaS subscription id>&from=2026-08-01&to=2026-08-31
```

Microsoft's usage report: one row per subscription, per day, per meter dimension (a row can split further when plan, price or private-offer attributes differ). Rows are **daily aggregates**, not individual usage events, so reconcile per day: sum what you reported for a dimension on a day and compare with `MeteredUsage`.

| Field | Meaning |
|---|---|
| `ReferenceId` | The SaaS subscription id (your join key) |
| `MarketplaceSubscriptionId` | The customer's Azure subscription, not the SaaS id |
| `UsageDate`, `MonthStartDate` | Day and month of usage (dates without a time zone; treat as UTC) |
| `MeterId`, `MeterDimension` | Dimension id you charge with, and its display name |
| `RawUsage`, `MeteredUsage`, `UsageUnit` | Quantity Microsoft recorded and billed, in the dimension's unit |
| `PriceCC`, `EstimatedPricePC`, `ListPriceUSD` | Unit price in customer currency, your payout currency, USD |
| `EstimatedExtendedChargeCC` / `PC` | `MeteredUsage` × unit price |
| `PartnerCenterDetectedAnomaly`, `PublisherMarkedAnomaly` | Microsoft's anomaly flags |

Filters are optional; without them you get the full history. In production the report refreshes nightly, so a charge appears the next day. Poll at most once a day.

### Revenue and payout

```http
GET /api/v1.0/financials
```

Microsoft's revenue report: one row per purchase line per month. Join `AssetId` to the SaaS subscription id; use `PurchaseRecordId` + `LineItemId` to identify a single purchase line. Metered usage appears as monthly SKU-level totals (`BillingModel: "UsageBased"`), not per dimension; use `/usage` for that.

Three currencies appear: **CC** is the customer's transaction currency, **PC** is your payout currency, **USD** is Microsoft's reporting currency. `TransactionAmount*` is what the customer was billed, `EarningAmount*` your share after Microsoft's fee, `IncentiveRate` your share in percent (for example `97`). Expected payout in your payout currency is `EstimatedRevenuePC × IncentiveRate / 100`. `PayoutStatus` runs Unprocessed → Upcoming → Sent, with `EstimatedPayoutMonth` and `PaymentSentDate`.

Payouts flow from Microsoft to your Partner Center account. WeTransact is not in the money path.

## Using the sandbox

The sandbox runs the production API code over an in-memory database; only Microsoft is faked. Its Microsoft is stateful: it remembers what it accepted per subscription, dimension and hour and answers `Duplicate` for it afterwards, exactly as the real one does. `GET /` returns a plain-text summary.

**Seeded data**

| Subscription id | Offer | Dimensions |
|---|---|---|
| `22222222-2222-2222-2222-222222222222` | Northwind On Demand (metered) | `transaction_tier_1` … `transaction_tier_6` |
| `11111111-1111-1111-1111-111111111111` | Northwind Insights (flat rate) | none |

The metered subscription comes with the previous seven days of usage across all six tiers, so `/usage` and `/financials` return data before you have charged anything. Accepted charges appear in both immediately.

**Fault injection.** Add the header `X-Sandbox-Outcome` to a charge request. Production ignores this header, so never send it there: the call would be a real charge.

| Value | What happens |
|---|---|
| `unavailable` | Microsoft never reached: 502, `Retryable: true`, no verdict. The retry lands |
| `lost` | Microsoft accepted but the response was lost: 502, `Retryable: true`. The retry with the same hour gets 409 Duplicate. **This is the case your retry loop must survive** |
| `duplicate` | Microsoft had already accepted this hour: 409 Duplicate, and stays 409 |
| `throttled` | 429 with an empty body |
| `resource-not-found`, `resource-not-authorized`, `resource-not-active`, `invalid-dimension`, `invalid-quantity`, `bad-argument`, `expired` | The corresponding Microsoft rejection, returned as 422 |
| `server-error` | Microsoft 500, returned as 502 `Retryable: true` |

`POST /sandbox/reset` (with the key) forgets every charge and restores the seeded history. Let in-flight calls finish first.

**What the sandbox does not do**

- Microsoft's real latency, real error message wording, and any verdict not listed above. Codes are Microsoft's; message text is modelled on Microsoft's documentation.
- The 10-requests-per-minute limit that production applies to the GET endpoints.
- Persist anything: state lives in memory and is lost on restart or reset.
- Your data: subscriptions, dimensions and reports are seeded examples.

Two production behaviours the sandbox reproduces on purpose: an unknown subscription id on `GET /subscriptions/{id}` answers 500 rather than 404, and dates from Microsoft's reports come back without a time zone.

## Suggested rollout

1. Onboard flat-rate offers first. They exercise activation, lifecycle events and payout reconciliation without touching the charge endpoint.
2. Build and test the charge retry loop against the sandbox, including the `lost` scenario and a parallel burst that produces 429s.
3. Move the consumption offer once the loop handles every status in the table above and your daily reconciliation against `/usage` closes.
