npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@devalade/adonis-chariow

v0.2.0

Published

Chariow integration for AdonisJS — checkout, licenses and signed Pulse webhooks

Downloads

96

Readme

@devalade/adonis-chariow

Sell through Chariow from an AdonisJS app: start a checkout, receive signed webhooks, gate your SaaS on a license key, and run subscriptions on top of licences.

Built for AdonisJS 7. Two runtime dependencies: zod for parsing what Chariow sends back, and better-result for typed failures.

Install

npm i @devalade/adonis-chariow
node ace configure @devalade/adonis-chariow

configure writes config/chariow.ts, adds a ChariowPulsesController, registers the provider and the chariow:check command, and declares the env variables.

CHARIOW_API_KEY=sk_live_...
CHARIOW_PULSE_SECRET=whsec_...

Get the API key at app.chariow.com/settings/api. Then confirm it works:

node ace chariow:check
# ✔ Connected to "Ma Boutique" (str_xyz789)

The API key is server-side only. Never ship it to a browser or a mobile app. Internally it is wrapped so it cannot reach a log line, a stack trace or JSON.stringify by accident.

Sell something

import chariow from '@devalade/adonis-chariow/services/main'

export default class CheckoutController {
  async store(ctx: HttpContext) {
    const result = await chariow.checkout.create(
      {
        product_id: 'prd_abc',
        email: ctx.request.input('email'),
        first_name: ctx.request.input('first_name'),
        last_name: ctx.request.input('last_name'),
        phone: { number: '97000000', country_code: '+229' },
      },
      ctx // ← forwards the buyer's IP
    )

    if (result.step === 'payment' && result.payment?.checkout_url) {
      return ctx.response.redirect(result.payment.checkout_url)
    }

    // A free product, or one this customer already owns.
    return ctx.response.redirect().toRoute('thank_you')
  }
}

step is 'payment', 'completed' or 'already_purchased' — a free product completes without a payment URL, so branch rather than assuming one is there.

Passing the HttpContext fills customer_ip from ctx.request.ip(). This endpoint is called server to server, so without it Chariow records your server's IP and resolves the buyer's country wrongly. payment_currency is filled from config.currency when you omit it.

Receive the sale

Point a Pulse at your app (Automations → Pulses → Add Pulse), then:

// start/routes.ts
router.post('/webhooks/chariow', [ChariowPulsesController])
// app/controllers/chariow_pulses_controller.ts
export default class ChariowPulsesController {
  async handle(ctx: HttpContext) {
    await chariow.pulses.handle(ctx, {
      'successful.sale': async (payload) => {
        await User.grantAccess(payload.customer.email, payload.product.id)
      },
      'license.revoked': async (payload) => {
        await User.revokeAccess(payload.license.id)
      },
    })
  }
}

handle verifies the HMAC signature over the raw body, parses the payload, skips deliveries it has already seen, runs your handler and answers 200. An unverified request throws PulseSignatureInvalid, which AdonisJS renders as a 401; a body that is not a recognised Pulse payload throws PulsePayloadUnexpected and renders as 400.

payload is typed per event, and the fields Chariow documents on every delivery — sale, product, customer, store — are non-optional, so there is nothing to guard against.

Three things to know:

  • The signing secret is not your API key. Each Pulse has its own whsec_… value, under Automations → Pulses → your Pulse → Overview. Nothing else will ever produce a matching digest.
  • Handlers are awaited. A handler that throws returns a non-2xx, and Chariow retries the delivery — which is what you want. Push slow work onto a queue rather than doing it inline.

If you use @adonisjs/shield, add the webhook path to exceptRoutes in config/shield.ts so CSRF protection does not block it.

Events

successful.sale, abandoned.sale, failed.sale, license.issued, license.activated, license.expired, license.nearing_expiry, license.revoked, affiliate.joined.

Add '*': (event, payload) => … to catch everything you have not handled explicitly.

Duplicate deliveries

Retries carry the same x-pulse-delivery-id, and the package remembers ids in memory for six hours so a handler runs once. Running several processes? Share the state:

defineConfig({
  dedupe: {
    seen: (id) => redis.exists(`pulse:${id}`).then(Boolean),
    remember: async (id) => void redis.setex(`pulse:${id}`, 21_600, '1'),
  },
})

Set dedupe: false to turn it off.

Gate your app on a license

const check = await chariow.licenses.check(licenseKey)

if (!check.valid) {
  // 'not_found' | 'revoked' | 'expired' | 'inactive'
  return response.forbidden({ reason: check.reason })
}

return check.license.expires_at

check() never throws for a key that does not exist — a customer mistyping their key is a normal path, not an exception. Results are cached for 60s (licenseCacheTtl), because the API allows only 100 requests per minute and you do not want to spend that budget on one user refreshing a page.

Device-limited licenses:

await chariow.licenses.activate(licenseKey, deviceId)
await chariow.licenses.activations(licenseKey)
await chariow.licenses.revoke(licenseKey) // permanent

Subscriptions

Chariow has no subscription resource — renewing means buying again, which issues a new licence rather than extending the old one. This derives the standing from the licences a customer holds, so your app stores no licence state.

const sub = await chariow.subscriptions.forCustomer(user.chariowCustomerId, {
  product_id: 'prd_pro',
})

if (!sub.isActive) {
  return response.forbidden({ status: sub.status })
}

if (sub.renewalDue) {
  // still working, but expiring — nudge them
  await mail.send(new RenewalReminder(sub.daysRemaining))
}

| status | Access | Meaning | |---|---|---| | none | no | No licence for this product | | pending | no | Issued, not yet activated | | active | yes | Comfortably in date, or lifetime | | expiring | yes | Works, but inside renewalWindowDays (default 7) | | expired | no | Past expires_at | | revoked | no | Revoked, and not renewable |

isActive is true for active and expiring — gate on that, not on status === 'active', or you will lock people out a week early. A lifetime licence (expires_at: null) is never expiring, so its holder is never nagged to pay again.

Looking it up

chariow.subscriptions.forCustomer(customerId, { product_id })  // 1 request — prefer this
chariow.subscriptions.forEmail(email, { product_id })          // 2 requests
chariow.subscriptions.forLicense(licenseKey)                   // 1 request

Store the Chariow customer_id against your user — every sale Pulse carries it as payload.customer.id — and forCustomer costs one request instead of two. Omit product_id to ask "does this person hold any subscription at all".

forEmail resolves the address through Chariow's customer search, which matches name or email, so it only accepts an exact case-insensitive email match. A near miss reads as none rather than handing someone another account's subscription.

Lookups are cached for subscriptionCacheTtl (default 60s) against the 100 requests/minute budget. The licences are cached, not the decision, so daysRemaining keeps counting down inside the window.

Renewing

const result = await chariow.subscriptions.renew(sub, {
  first_name: user.firstName,
  last_name: user.lastName,
  phone: { number: user.phone, country_code: '+229' },
}, ctx)

return response.redirect(result.payment.checkout_url)

Product and email come from the subscription's licence; you supply what a licence does not carry. The customer's full name is deliberately not split into first and last — that guess is wrong too often to make quietly.

There is no automatic charging: Chariow has no stored-payment or recurring-charge API, so renewal is always the customer going through checkout again.

Lifecycle events

Point the same Pulse endpoint at business language instead of event names:

export default class ChariowPulsesController {
  async handle(ctx: HttpContext) {
    await chariow.subscriptions.handle(ctx, {
      onStarted:    async (sub) => access.grant(sub),      // license.issued
      onActivated:  async (sub) => access.grant(sub),      // license.activated
      onRenewalDue: async (sub) => mail.remind(sub),       // license.nearing_expiry
      onLapsed:     async (sub) => access.revoke(sub),     // license.expired
      onCancelled:  async (sub) => access.revoke(sub),     // license.revoked
    })
  }
}

The delivered payload already carries the whole licence, so sub arrives with daysRemaining and expiresAt filled in — no extra API call. Signature verification, payload parsing and delivery de-duplication are the same as pulses.handle, and sale events reaching the same endpoint are answered 200 and ignored.

Everything else

await chariow.store.get()

await chariow.products.list({ per_page: 20, type: 'license' })
await chariow.products.get('my-course') // id or slug

await chariow.sales.list({ status: 'completed' })
await chariow.sales.get('sal_xyz')

await chariow.customers.list({ search: 'ada@' })
await chariow.discounts.list({ status: 'active' })
await chariow.pulses.list()

await chariow.affiliates.get('ADA10')
await chariow.affiliates.invite(['[email protected]'])

Every listing is cursor-paginated. list() gives you one page; all() walks them all:

for await (const sale of chariow.sales.all({ status: 'completed' })) {
  console.log(sale.amount.formatted)
}

Payload and response keys match the Chariow API docs exactly, so you can copy an example straight out of the docs and it type-checks.

Errors

Every failure is a tagged class carrying _tag, status, code and safe structured fields. The methods above throw them, and AdonisJS renders the status:

| Class | _tag | Status | Extra | |---|---|---|---| | ChariowUnauthorized | ChariowUnauthorized | 401 | responseStatus | | ChariowNotFound | ChariowNotFound | 404 | | | ChariowValidationFailed | ChariowValidationFailed | 422 | errors | | ChariowRateLimited | ChariowRateLimited | 429 | retryAfter | | ChariowResponseUnexpected | ChariowResponseUnexpected | 502 | issues | | ChariowRequestFailed | ChariowRequestFailed | 500 | responseStatus, cause | | PulseSignatureInvalid | PulseSignatureInvalid | 401 | reason | | PulsePayloadUnexpected | PulsePayloadUnexpected | 400 | issues |

Each carries operation, so you know which call failed, and each message starts with Chariow's own. Reads retry twice on 429/5xx, honouring Retry-After. Checkout is never retried — a retried checkout is a duplicate sale.

Failures as values

chariow.client and chariow.pulses.verify() return Result from better-result instead of throwing, for callers who would rather branch:

const result = await chariow.client.get('/store', { operation: 'getStore', schema: StoreSchema })

if (Result.isError(result)) {
  logger.warn({ operation: result.error.operation, tag: result.error._tag }, 'chariow down')
  return
}

Responses are parsed, not trusted

Chariow's replies are parsed before you see them. A response missing a documented field raises ChariowResponseUnexpected naming the field, rather than handing you an object whose types promise more than it contains. Fields Chariow adds that this package does not model yet are preserved, so an upgrade on their side does not silently drop data.

Failure issues name the field path and the type problem — never the received value, so you can log them without leaking a customer's data.

Configuration

defineConfig({
  apiKey: env.get('CHARIOW_API_KEY'),
  pulseSecret: env.get('CHARIOW_PULSE_SECRET'), // or { pulse_abc: 'whsec_…' } per Pulse
  currency: 'XOF',        // default payment_currency
  baseUrl: 'https://api.chariow.com/v1',
  timeout: 15_000,        // ms
  retries: 2,             // reads only
  licenseCacheTtl: 60_000, // ms, 0 disables
  renewalWindowDays: 7,    // days before expiry that count as "expiring"
  subscriptionCacheTtl: 60_000, // ms, 0 disables
  dedupe: undefined,      // false, or your own store
  fetch: undefined,       // override for tests
  now: undefined,         // override the clock for tests
})

Testing your integration

Pass fetch to stub the API, with no mock framework involved:

const chariow = new Chariow({
  apiKey: 'sk_live_test',
  fetch: async () => Response.json({ message: 'ok', data: license, errors: [] }),
})

Pass now to drive the licence cache and the de-duplication window instead of waiting:

let clock = 1_000_000
const chariow = new Chariow({ apiKey, now: () => clock })
clock += 61_000 // the cached licence has now expired

To test your own Pulse controller, sign a body the way Chariow does:

const raw = JSON.stringify({ event: 'successful.sale', sale: { id: 'sal_1' }, /* … */ })
const signature = 'sha256=' + createHmac('sha256', secret).update(raw).digest('hex')

License

MIT