The Interledger Community 🌱

Cover image for Qwyre.com - A guide for integrating Coil & Web Monetization APIs for creatives doing the job once
Gavin Chait for Qwyre

Posted on

Qwyre.com - A guide for integrating Coil & Web Monetization APIs for creatives doing the job once

There are two principle types of developer: journeymen and creatives. A journeyman gains experience through repetition. Overly-technical or complex documentation isn't a problem since the journeyman uses it more like landmarks. The creative coder is developing software as a means to express their idea. They know where they want to go but are covering the ground once.

This guide for integrating Coil & Web Monetization APIs is written for creatives.

Why implement the API? It is your responsibility - as creative - to ensure that your prospective subscribers and supporters can easily, and quickly, i) understand what it is that you are offering, and ii) pay for it in a way that is easy and doesn't scare them. You cannot rely on them to install a browser plugin for a technology they may never have heard of.

There is a secondary reason ... the API offers you more flexibility and creativity in what you can offer. It is more expressive.


Notes to the guide

Approach

The first step is to have a high-level understanding of exactly what needs to happen to authenticate a subscriber and authorise web monetization. This may feel like repetition, but that means we go through this twice. First to get a broad understanding, then into the technical detail and gotchas.

I recommend you follow along in the reference documentation (see References at the end), and this guide. This guide compliments the documentation and is not a substitute for it.

Development stack

I make the assumption you know how to code and that you know enough about best-practice in app development that I won't need to include everything. I provide most code required for implementation, but the tech stack is very specific:

  • Nuxt.js - A Vue.js framework, based on Node.js, for the Progressive Web App frontend,
  • FastAPI - A Python framework for building APIs, for the server backend.

The coding languages are TypeScript and Python with annotations (specifically Pydantic). These annotations ensure code consistency and reduce the number of gotchas.

The complete development stack is available as a Docker Compose base project generator:

GitHub logo whythawk / full-stack-fastapi-postgresql

Full stack, modern web application generator. Using FastAPI, PostgreSQL as database, Docker, automatic HTTPS and more.

Gotchas

There are a LOT of gotchas. These will be presented as:

GOTCHA: a gotcha is an easy-to-miss detail, which - once overlooked - will cause you hours / days of lost time.

Consider this a first draft, and please do respond in the comments below if anything is unclear, or you see a better / more efficient way of doing things.

Let's get started.


Web Monetization is about authentication and authorisation

Your app is in the middle between your subscribers and Coil as monetization provider. Your objectives are authentication - prove who you are - and authorisation - prove you have authority to perform an action.

  • Your app needs to perform each of these tasks with each party.
  • You need to tool up your app so that it has the necessary credentials and scripts to perform these tasks.

There are four separate components you'll need to build:

monetization-guide-flowchart

  • Authenticate your app with Coil: you'll do this once for each app - a minimum of twice, for your development and production environments, and is largely a manual process with application via email and an online form,
  • Embed the OAuth Web Monetization (OWM) script in your app: again, once, as you develop your app,
  • Your subscriber authenticates your rights to their monetisation account: for each new subscriber,
  • Your subscriber authorises your right to monetize your content: for each subscriber, every time they permit monetization, and refreshed every 30 minutes.

This is how your subscriber will experience the process:

1. They visit your app

monetization-app-landing-page

2. They decide to authorise your app to receive payments and click on a link you generate

monetization-app-sign-in-with-coil

3. They are referred to Coil where they must approve authentication.

monetization-app-subscriber-coil-auth

4. Coil refers the subscriber back to a specific receiving page where your app needs to process the token it receives and authenticate.

monetization-app-authenticating

5. Your app updates the subscriber and invites them to begin streaming.

monetization-app-ready-to-stream

Only one of these steps requires your subscriber to leave your app and make an active authentication decision. Thereafter, how you decide to start and stop monetization is up to you.

Now for the technical details.


1. Authenticate your app with Coil

image

At the end of this process you will have:

  • client_id,
  • client_secret converted to base64.

This is a strictly manual process and takes some time. Do it earlier rather than later.

GOTCHA: before you decide on the redirect URI pause to think. This URI will become the most important page in managing this process, and it may not work for you if that page is root.

Once you receive your app authentication token from Coil, you will need to register with Coil's Open ID Connect (OIDC) provider to exchange the token for a client ID and client secret. You can do this from a bash command line.

Coil's documentation is relatively straightforward. Dig out the app registration terms you used above:

request:

curl -X POST https://coil.com/oauth/reg \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer Pb8w98v18ikkZyy26nxXK5OKDDsN6kfEJVmQ2id9tbC' \
  -d \
  '{
    "redirect_uris":["http://localhost:3000","http://localhost:3000/support-us"],
    "client_name": "qwyre-local",
    "tos_uri": "https://localhost:3000/privacy/",
    "policy_uri": "https://localhost:3000/privacy/",
    "logo_uri": "http://localhost:3000/qwyre-logo.svg"
  }'
Enter fullscreen mode Exit fullscreen mode

GOTCHA: tos_uri and policy_uri must be https otherwise the request will fail. It doesn't matter whether your development environment is not certified (although you can issue yourself a self-signed certificate if you like). The end-points are not tested, only the form of the URI.

response:

{
  "client_id": "314ac134-fc3c-4d28-bf43-ccb75a2f9fb2",
  "client_secret": "uVE2t7y1QvyM78PlBA3aQAUh6syXVw7P2XBr4QDsS2yrkETR6al9YFpH4NDloXh5",
}
Enter fullscreen mode Exit fullscreen mode

This is abbreviated, and is the only time you'll get this, so save these details somewhere safe. These are the only two keys you need (for now): client_id and client_secret.

GOTCHA: Not really a gotcha, since it's clearly flagged in the docs, however, before you can use your client_secret, you need to make the string web-safe since you will be sending this in your first GET request. That requires converting it to base 64. In Python, that's pretty easy:

import base64
base64.b64encode(bytes(client_secret, 'utf-8'))
Enter fullscreen mode Exit fullscreen mode

Then use that string as your client_secret.


2. Embed the OAuth Web Monetization (OWM) script in your app

monetization-2-embed-owm-script

At the end of this process you will have:

  • Embedded Coil's OWM script in your app.

Coil describes this as a relatively straightforward process. It is not.

GOTCHA: the Coil OWM script does not play well with Nuxt.js and, I'm assuming, any Node-based JavaScript/TypeScript framework that performs server-side-rendering and hydration. If you don't know what this means, just be aware that this process is the source of many gotchas, not just when implementing web monetization.

Coil recommends you create a base index.html file as follows:

<html>
  <head>
    <meta name="monetization" content="$wallet.example.com/~alice">
    <script>
      if (document.monetization) {
        document.monetizationExtensionInstalled = true
      } else {
        document.monetization = document.createElement('div')
        document.monetization.state = 'stopped'
      }
    </script>
    <script src="https://cdn.coil.com/coil-oauth-wm.v7.beta.js" defer>
    </script>
  </head>
  <body>
    <p>Testing web monetization via coil polyfill using btp token.</p>
    <script>
      document.coilMonetizationPolyfill.init({ btpToken: 'e0y1...' })
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

You'll notice three important components:

  • The meta tag with the destination wallet for streaming payments,
  • A script tag with defer import of the OWM script,
  • And an if else statement setting up the div for interacting with document.monetization.

Something that I spent quite a bit of time frustrated by is that I couldn't interact with the OWM script in any way until after it had been initialised by creating that seemingly innocuous div. This has implications for the way Node interacts with the script.

The recommended way to include external / third-party scripts in Node is via a config.js file. For example:

// Global page headers: https://go.nuxtjs.dev/config-head
// https://vue-meta.nuxtjs.org/api/#script
// https://javascript.info/script-async-defer
head: {
    title: "Qwyre",
    meta: [
      { charset: "utf-8" },
      { name: "viewport", content: "width=device-width, initial-scale=1" },
      { name: "monetization", content: process.env.COIL_PAYMENT_POINTER },
      { hid: "description", name: "description", content: "" },
      { src: "https://cdn.coil.com/coil-oauth-wm.v7.beta.js", defer: true },
    ],
    link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" }],
}
Enter fullscreen mode Exit fullscreen mode

Then you would initialise the script in your layout (base html templates to structure any pages). This does not work.

At this time, you need to bypass Node/Nuxt.js and initialise your app.html (ordinarily not customised, and left to the framework to manage):

    <!DOCTYPE html>
    <html {{ HTML_ATTRS }}>
      <head {{ HEAD_ATTRS }}>
        {{ HEAD }}
        <script>
            if (document.monetization) {
              document.monetizationExtensionInstalled = true
            } else {
              document.monetization = document.createElement('div')
              document.monetization.state = 'stopped'
            }
          </script>
          <script src="https://cdn.coil.com/coil-oauth-wm.v7.beta.js" defer></script>
      </head>
      <body {{ BODY_ATTRS }}>
        {{ APP }}
      </body>
    </html>
Enter fullscreen mode Exit fullscreen mode

Even then, you will get some flaky responses. I am unclear as to whether this is normal, or related to the compromises.


3. Your subscriber authenticates your rights to their monetisation account

monetization-3-subscriber-auth

At the end of this process you will have:

  • Set up TypeScript interfaces for requests and responses,
  • Set up all API request code,
  • Set up a proxy for queries to Coil,
  • Store the refresh_token as a cookie, and access_token and sub in the store,
  • [Optional] Request your subscriber's personal information.

Maybe go get some coffee. This is going to get very technical, very fast.

Set up your TypeScript interfaces

It is very easy to get lost amongst the keys and their requirements. TypeScript interfaces permit us to define what data we need to request, and what we expect in response. These are documented over several pages, but we'll pool them all in one, then use them throughout what follows. It will make things easier.

Create ./interfaces/coil.ts:

    /* eslint-disable camelcase */

    export interface ICoilUserResourceRequest {
      response_type: string
      scope: string
      client_id: string
      state: string
      redirect_uri: string
      prompt: string
    }

    export interface ICoilUserResourceResponse {
      code: string
      state: string
      scope: string
    }

    export interface ICoilUserTokenRequest {
      code: string
      grant_type: string
      redirect_uri: string
    }

    export interface ICoilUserTokenResponse {
      access_token: string
      expires_in: number
      id_token: string
      refresh_token: string
      scope: string
      token_type: string
    }

    export interface ICoilUserTokenRefreshRequest {
      refresh_token: string
      grant_type: string
      scope: string
    }

    export interface ICoilUserTokenRevokeRequest {
      token: string
    }

    export interface ICoilUserInfo {
      email?: string
      sub: string
    }

    export interface ICoilUserBTP {
      btpToken: string
    }
Enter fullscreen mode Exit fullscreen mode

That should be fairly readable. Interfaces for request are followed by a counterparty response. We make the request, we receive the response. It should all validate.

Set up all API request code

We'll go through this step-by-step ... I'm using Axios to make the requests.

1. Generate and present the referral URI to your subscriber

GOTCHA: While Coil states that you can refer the subscriber to either of a signup or login page, the reality is only login works. Note this for the prompt key in the API.

In Nuxt.js, create ./api/coil.ts:

import axios from "axios"
import {
  ICoilUserResourceRequest,
  ICoilUserTokenRequest,
  ICoilUserTokenResponse,
  ICoilUserTokenRefreshRequest,
  ICoilUserTokenRevokeRequest,
  ICoilUserInfo,
  ICoilUserBTP,
} from "@/interfaces"

export const coil = {

    getUserResource(coilState: string): string {
        // https://help.coil.com/docs/dev/get-oauth-auth#request-parameters
        // NOTE: currently the 'signup' prompt doesn't work
        // https://www.valentinog.com/blog/url/
        const getResourceURL = new URL(`${process.env.coilUrl}/oauth/auth`)
        const data: ICoilUserResourceRequest = {
          response_type: "code",
          scope: "simple_wm openid",
          client_id: (process.env.coilID as unknown) as string,
          state: coilState,
          redirect_uri: `${process.env.baseUrl}/support-us`,
          prompt: "login",
        }
        for (const [key, value] of Object.entries(data)) {
          getResourceURL.searchParams.append(key, value)
        }
        return getResourceURL.href
    }

}
Enter fullscreen mode Exit fullscreen mode

GOTCHA: coilState is a really important token you will use to ensure you can trust the response from Coil (i.e. no spoofing of your website by sending random scripts to your processing page). Effectively, you generate a random string, send that to Coil along with your request, then Coil sends it back. It is YOUR responsibility to validate the state you receive as being identical to the one you sent. I generate these as UUIDs:

In frontend create ./utilities/keys.ts:

function generateUUID(): string {
  // Reference: https://stackoverflow.com/a/2117523/709884
  // And: https://stackoverflow.com/a/61011303/295606
  return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (s) => {
    const c = Number.parseInt(s, 10)
    return (
      c ^
      (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
    ).toString(16)
  })
}
Enter fullscreen mode Exit fullscreen mode

You will call and use the generated URI in your redirect_uri page script.

In frontend, redirect processing page ./pages/support-us.vue:

this.coilRequestURI = coil.getUserResource(this.coilState)
Enter fullscreen mode Exit fullscreen mode

And then use this to generate the "Sign in with Coil" button:

<a class="mt-9" :href="coilRequestURI"
  ><SvgIcon terms="w-56 h-10" icon="svgCoilWhite"
/></a>
Enter fullscreen mode Exit fullscreen mode

Note: you defined which page you want Coil to return the subscriber's response as redirect_uri. It is this page where all the following processing will take place. Mine is ./pages/support-us.vue.

GOTCHA: You can't make requests from the frontend. You run straight into Cross-Origin Resource Sharing (CORS) errors where the requests and / or responses are blocked. You need to proxy your requests. This is especially painful if you were hoping for a Single-Page Application, or a static app.

Fortunately, I'm using FastAPI on the backend so it's relatively straightforward to set it up to proxy requests. We'll do that in the next section, so for here just note that the URIs for axios.post and axios.get are being requested from the backend, not Coil.

2. Receive and use response token to request subscriber authentication keys

The coil redirect will return to your response page, along with query terms in the path. It may look something like this:

response:

https://example.com/
?code=CU6LG36vKvVmUbF9QWFwj7F5zvY
&state=b5f1872f-9d32-5f31-819d-5a4daeab4ea9
&scope=simple_wm openid
Enter fullscreen mode Exit fullscreen mode

First, parse this to extract the parameters and then send this for processing. I'm using the Nuxt Store to manage browser state.

In frontend, in redirect processing page ./pages/support-us.vue:

if (
  this.$route.query.code &&
  this.$route.query.state &&
  this.$route.query.scope
) {
  const payload: ICoilUserResourceResponse = {
    code: this.$route.query.code as string,
    state: this.$route.query.state as string,
    scope: this.$route.query.scope as string,
  }
  await this.getUserToken(payload)
}
Enter fullscreen mode Exit fullscreen mode

The asyncronous await to getUserToken populates the store and triggers the cascade of authentication requests we need. In ./store/monetization/actions.ts:

async getUserToken({ commit, dispatch }, payload: ICoilUserResourceResponse) {
    if (getCoilState() !== payload.state) {
      await commit("setRequestError", true)
    } else {
      try {
        try {
          const response: AxiosResponse = await coil.getUserToken(payload.code)
          const data: ICoilUserTokenResponse = response.data
          await commit("setCoilToken", data.access_token)
          saveCoilRefresh(data.refresh_token)
        } catch (error) {
          await dispatch("checkCoilError", error)
        }
      } catch (error) {
        await dispatch("checkCoilError", error)
      }
    }
}
Enter fullscreen mode Exit fullscreen mode

This requires a request to the coil API, and then we save the authentication token to the store, and the refresh token as a persistent cookie in the subscriber's browser.

This is the critical self-validation call to ensure that the response you receive is valid and from Coil:

if (getCoilState() !== payload.state) ...
Enter fullscreen mode Exit fullscreen mode

Here is the next api call to Coil to pass back the token you received and exchange it for your subscriber's authentication and refresh tokens:

await coil.getUserToken(payload.code)
Enter fullscreen mode Exit fullscreen mode

Back to ./api/coil.ts and pop this into the export:

async getUserToken(coilCode: string) {
    // https://help.coil.com/docs/dev/post-oauth-token/index.html#request-an-access-token-for-an-authenticated-user
    const data: ICoilUserTokenRequest = {
      code: coilCode,
      grant_type: "authorization_code",
      redirect_uri: `${process.env.baseUrl}/create`,
    }
    return await axios.post<ICoilUserTokenResponse>(
      `${process.env.apiUrl}/api/v1/coil/proxy/oauth/token`,
      data,
      requestHeaders()
    )
}
Enter fullscreen mode Exit fullscreen mode

This is a post and you pass your app's authentication keys to Coil in the header:

requestHeaders()
Enter fullscreen mode Exit fullscreen mode

That function is included in ./api/coil.ts:

function requestHeaders() {
  const token = btoa(`${process.env.coilID}:${process.env.coilSecret}`)
  return {
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${token}`,
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

GOTCHA: Note again that you're escaping your two keys, client_id and client_secret, as base64. Here using btoa. I don't know if this was just me, but I found that I had to do this 'twice'. I first converted client_secret to base64, then generated a new base64 string on the result btoa(client_id:client_secrect_base64).

Coil returns a response json object.

response:

{
  "access_token": "eyJhbGciOi...JSUzI1NfsQ",
  "expires_in": 3600,
  "id_token": "eyJhbGciOiJSUz...I1NiIsInR5",
  "refresh_token": "dzfKQUEFYXEZ2~WKq5t0atT36X~",
  "scope": "simple_wm", "openid",
  "token_type": "Bearer"
}
Enter fullscreen mode Exit fullscreen mode

Store the refresh_token as a cookie, and access_token and sub in the store

This access_token authenticates you to request a BTP token which is what you need to begin streaming money. This token is only valid for an hour, and so I don't bother storing it in a persistent cookie, leaving it in the store. However, the refresh_token is valid indefinitely and so you need to take care with it.

ETHICS: You may be tempted to store this refresh_token in your database. DO NOT DO SO. It doesn't belong to you. It belongs to your subscriber. Should they wish to revoke your use of that token, it should never be a request to your database. They should have the power to simply clear their browser cookies.

Request your subscriber's personal information

Optional: Depending on how your app works, you may want your subscriber's personal information (user id and/or email). This is a separate request. It is not needed to authorise streaming.

In the store ./store/monetization/actions.ts:

async getUserInfo({ commit, dispatch, state }) {
    try {
      const response: AxiosResponse = await coil.getUserInfo(state.coilToken)
      const data: ICoilUserInfo = response.data
      await commit("setCoilSub", data.sub)
    } catch (error) {
      await dispatch("checkCoilError", error)
    }
}
Enter fullscreen mode Exit fullscreen mode

Back in ./api/coil.ts:

async getUserInfo(accessToken: string) {
    return await axios.get<ICoilUserInfo>(
      `${process.env.apiUrl}/api/v1/coil/proxy/user/info`,
      requestUserHeaders(accessToken)
    )
}
Enter fullscreen mode Exit fullscreen mode

GOTCHA: you are using both a new API endpoint for this request, as well as a new header. https://api.coil.com, and this header:

function requestUserHeaders(accessToken: string) {
  return {
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Bearer ${accessToken}`,
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

Where accessToken is the access_token you received from getUserToken.

In addition, your request to getUserInfo is a get request. That's a minor change here, but a big change on your proxy.

response:

{
  "sub": "22028a7c-b720-7a3e-4387-6c9845f6201b",
  "email": "alice@coil.com"
}
Enter fullscreen mode Exit fullscreen mode

Set up a backend proxy for queries to Coil

As described earlier, all of the API calls in the Nuxt frontend are proxied to the FastAPI backend.

The way this works is, your frontend interacts with the subscriber and manages the monetization state. It then sends any queries it has direct to the server and immediately refers back any responses. It stores nothing and remembers nothing.

Python code follows, using HTTPX as the http client.

ETHICS: You'll see that, while your subscriber is anonymous (as are their payments), control over when to charge them and how to do so is not actually in their control. It's entirely in yours. That means you have to deliberately - from the beginning - make it easy to NOT ABUSE YOUR SUBSCRIBER. The only way you can do that effectively is if you resolve never to store any auth keys anywhere near your backend, but leave them entirely in your subscriber's browser. This is the reason we only proxy at the backend and not manage the entire process.

What follows is longer than it needs to be because of yet another gotcha.

GOTCHA: Coil's API has two inconsistencies. The first is that not all requests are POST. There is one GET. The second is that not all requests are to the same URI. It switches from https://coil.com to https://api.coil.com. You need to account for this in your proxy.

Create ./app/api/api_v1/endpoints/coil.py:

    from typing import Any
    from fastapi import APIRouter, HTTPException, Request, Response
    import httpx

    from app.core.config import settings


    router = APIRouter()
    # https://coil.com
    coil_uri = settings.COIL_URI
    # https://api.coil.com
    coil_api_uri = settings.COIL_API_URI


    # FOR POST
    @router.post("/proxy/{path:path}")
    async def proxy_post_request(*, path: str, request: Request) -> Any:
        # https://www.starlette.io/requests/
        # https://www.python-httpx.org/quickstart/
        # https://github.com/tiangolo/fastapi/issues/1788#issuecomment-698698884
        # https://fastapi.tiangolo.com/tutorial/path-params/#__code_13
        try:
            URI = coil_uri
            if path.startswith("user/"):
                URI = coil_api_uri
            data = await request.json()
            headers = {
                "Content-Type": request.headers["Content-Type"],
                "Authorization": request.headers.get("Authorization"),
            }
            async with httpx.AsyncClient() as client:
                proxy = await client.post(f"{URI}/{path}", headers=headers, data=data)
            response = Response(content=proxy.content, status_code=proxy.status_code)
            return response
        except Exception as e:
            raise HTTPException(status_code=403, detail=str(e))

    #FOR GET
    @router.get("/proxy/{path:path}")
    async def proxy_get_request(*, path: str, request: Request) -> Any:
        try:
            URI = coil_uri
            if path.startswith("user/"):
                URI = coil_api_uri
            headers = {
                "Content-Type": request.headers.get("Content-Type", "application/x-www-form-urlencoded"),
                "Authorization": request.headers["Authorization"],
            }
            async with httpx.AsyncClient() as client:
                proxy = await client.get(f"{URI}/{path}", headers=headers)
            response = Response(content=proxy.content, status_code=proxy.status_code)
            return response
        except Exception as e:
            raise HTTPException(status_code=403, detail=str(e))
Enter fullscreen mode Exit fullscreen mode

That's all you need on the server, but let me give you a little context and explanation.

@router.post("/proxy/{path:path}")
Enter fullscreen mode Exit fullscreen mode

FastAPI uses types and Pydantic to annotate variables. What's going to happen here is that all the text after path is going to be treated as a path type and provided as a variable to the function. We then use httpx to make a new post to Coil. Note, though, how we have to account for the gotcha of shifting endpoints.

URI = coil_uri
if path.startswith("user/"):
    URI = coil_api_uri
Enter fullscreen mode Exit fullscreen mode

Then we regenerate our headers, get any data from the request in json/dict format, and proxy using httpx:

data = await request.json()
async with httpx.AsyncClient() as client:
    proxy = await client.post(f"{URI}/{path}", headers=headers, data=data)
Enter fullscreen mode Exit fullscreen mode

GOTCHA: Coil response codes are a little unclear. The only failure code is a 403 request failure. You do get some text to explain the error, and you can send that back to the app, but I found it often quite obscure. It wasn't always clear what I had done wrong, or what wasn't working.


4. Your subscriber authorises your right to monetize your content

monetization-4-monetization

At the end of this process you will have:

  • Authorise your rights to monetize a subscriber,
  • Initialise your web monetization stream, and specify a destination wallet,
  • Refresh streaming tokens as required,
  • Stop / Revoke streaming in response to your subscriber's wishes.

Authorise your rights to monetize a subscriber

A btpToken - which is a promise to fulfill a payment request, not payment in and of itself - is only valid for 30 minutes. There is no point in generating one until your subscriber actually expresses their intention to stream money to your wallet.

You'll need some sort of "start" / "stop" UX on your app, and the Web Monetization Foundation has a great set of docs that explain exactly this. You can review those after this section. First we use the subscriber's authentication token to request a BTP token.

In the frontend store ./store/monetization/actions.ts:

async getUserBTP({ commit, dispatch, state }) {
    try {
      const response: AxiosResponse = await coil.getUserBTP(state.coilToken)
      const data: ICoilUserBTP = response.data
      await commit("setBtpToken", data.btpToken)
    } catch (error) {
      await dispatch("checkCoilError", error)
    }
}
Enter fullscreen mode Exit fullscreen mode

Back in frontend ./api/coil.ts:

async getUserBTP(accessToken: string) {
    return await axios.post<ICoilUserBTP>(
      `${process.env.apiUrl}/api/v1/coil/proxy/user/btp`,
      {},
      requestUserHeaders(accessToken)
    )
}
Enter fullscreen mode Exit fullscreen mode

There's no point in storing the token outside of the store. It is valid for only 30 minutes from the time of issue, whether it is used or not.

GOTCHA: the request to get the btpToken is both in camel-case, and a post. It also has no data to send in post, so ensure you include an empty object: {}. It also uses https://api.coil.com. Both the request to get user info, and BTP token are the only requests to this root endpoint.

response:

{
  "btpToken": "eyJhbGciOiJCchQ8...SeOz98I2Sqyf1LrVM"
}
Enter fullscreen mode Exit fullscreen mode

Now you have a btpToken, but possession of a token is not money. That still needs to happen in the next process.

Initialise your web monetization stream, and specify a destination wallet

We'll start with the gotcha.

GOTCHA: The challenge of websites is also the purpose of JavaScript web frameworks, like Node. To manage, and persist, state. The more you can persist between state changes (shifting between different web pages) the more a website can feel like a native application. A Progressive Web App is all about near native user-experience. Problem is, the current Coil OWM script does not seem to expect this. Their documentation expressly states "Add the following code to the <body> section of each page you are monetizing.":

<script>
    document.coilMonetizationPolyfill.init({ btpToken: 'USERS_TOKEN_HERE' })
</script>
Enter fullscreen mode Exit fullscreen mode

Doing that in a Node app, though, will instantly crash your app during transitions since this is exactly the sort of state that Node will preserve. That means initialising the coilMonetizationPolyfill needs to look like this on every page:

if (
  process.client &&
  Object.prototype.hasOwnProperty.call(document, "coilMonetizationPolyfill")
)
  try {
    document.coilMonetizationPolyfill.init({ btpToken: this.btpToken })
  } catch (error) {}
Enter fullscreen mode Exit fullscreen mode

In words: check that your script will execute only client-side, and that the coilMonetizationPolyfill has been initialised, and then try/catch to initialise it with the btpToken.

I created a single "start" / "stop" component to listen to subscriber instructions.

In frontend ./components/monetization/MenuToggle.vue:

public setMonetizationTag() {
    // https://github.com/Techgethr/webmonvuejs/blob/main/index.js
    // https://webmonetization.org/docs/start-stop/
    const monetizationTag = document.querySelector('meta[name="monetization"]')
    if (!monetizationTag && this.coilPointer) {
      const meta = document.createElement("meta")
      meta.name = "monetization"
      meta.content = this.coilPointer
      document.getElementsByTagName("head")[0].appendChild(meta)
    }
    if (monetizationTag && !this.coilPointer) {
      monetizationTag.remove()
    }
}
Enter fullscreen mode Exit fullscreen mode

ETHICS: The way that the OWM script "listens" to whether or not a valid BTP token should be used to stream payments is whether or not there is a metatag pointing to a wallet. You end streaming payments by removing the pointer. Everyone, from your subscriber, to Coil, to the Web Monetization Foundation and Grant for the Web are relying on you to honour your subscriber's intentions and expressed instructions.

When your subscriber tells you to stream, you add the wallet pointer:

if (!monetizationTag && this.coilPointer) {
  const meta = document.createElement("meta")
  meta.name = "monetization"
  meta.content = this.coilPointer
  document.getElementsByTagName("head")[0].appendChild(meta)
}
Enter fullscreen mode Exit fullscreen mode

And when they tell you to stop, you remove it:

if (monetizationTag && !this.coilPointer) {
  monetizationTag.remove()
}
Enter fullscreen mode Exit fullscreen mode

That's the entirety of your subscriber's protection from abuse of their trust in you. That you MUST honour their instructions and delete the wallet pointer. Please - and I know I'm whispering in a thunderstorm - please don't abuse this.

Refresh streaming tokens as required

"Start" and "Stop" is equivalent to a call to check the validity of a token, and refresh them as required.

In the same frontend component ./components/monetization/MenuToggle.vue

public async toggleMonetizationStreaming() {
    if (!this.isStreaming) await this.startMonetizationStream()
    else await this.stopMonetizationStream()
    this.setMonetizationTag()
}
Enter fullscreen mode Exit fullscreen mode

This is in response to the subscriber toggling a button in the menu bar of the app.

It is your responsibility to check that a BTP token is still valid. A JWT token intrinsically contains the time it was created, so you can easily check.

In frontend ./utilities/keys.ts:

function getTimeInSeconds(): number {
  // https://stackoverflow.com/a/3830279/295606
  return Math.floor(new Date().getTime() / 1000)
}

function tokenExpired(token: string) {
  // https://stackoverflow.com/a/60758392/295606
  const expiry = JSON.parse(atob(token.split(".")[1])).exp
  return getTimeInSeconds() >= expiry
}
Enter fullscreen mode Exit fullscreen mode

And then on to the frontend store, where we manage monetization state ./store/monetization/actions.ts:

  async startMonetizationStream({ commit, dispatch, state }) {
    await dispatch("refreshUserBTP")
    if (!state.requestError) {
      await commit("setIsStreaming", true)
      await commit("setCoilPointer", process.env.coilPointer as string)
      await commit(
        "main/addNotification",
        {
          content: "You have started web monetization.",
          color: "success",
        },
        { root: true }
      )
    }
  }
Enter fullscreen mode Exit fullscreen mode

Refreshing the BTP token triggers a cascade of requests. I felt there was no point in worrying about the difference between an authentication_token valid for 60 minutes, and a btpToken valid for 30. Just refresh both of them.

In the frontend ./store/monetization/actions.ts:

async refreshUserBTP({ commit, dispatch, state }) {
    if (getCoilRefresh()) {
      try {
        const response: AxiosResponse = await coil.getUserRefresh(
          getCoilRefresh() as string
        )
        const data: ICoilUserTokenResponse = response.data
        // https://stackoverflow.com/a/32108184/295606
        if (
          data &&
          Object.keys(data).length !== 0 &&
          data.constructor === Object
        ) {
          await commit("setCoilToken", data.access_token)
          saveCoilRefresh(data.refresh_token)
          if (!state.requestError) {
            await dispatch("getUserBTP")
            await commit("setRequestError", false)
          }
        } else {
          await commit("setRequestError", true)
        }
      } catch (error) {
        await dispatch("checkCoilError", error)
      }
    } else {
      await commit("setRequestError", true)
      await commit(
        "main/addNotification",
        {
          content: "Error authorising web monetization.",
          color: "error",
        },
        { root: true }
      )
    }
}
Enter fullscreen mode Exit fullscreen mode

You'll note that there are profoundly numerous points in the process where things can go wrong and try / catch is triggered.

With the frontend api request in ./api/coil.ts:

async getUserRefresh(refreshToken: string) {
    const data: ICoilUserTokenRefreshRequest = {
      refresh_token: refreshToken,
      grant_type: "refresh_token",
      scope: "simple_wm openid",
    }
    return await axios.post<ICoilUserTokenResponse>(
      `${process.env.apiUrl}/api/v1/coil/proxy/oauth/token`,
      data,
      requestHeaders()
    )
}
Enter fullscreen mode Exit fullscreen mode

With that you can get your BTP token, as described above.

Stop / Revoke streaming in response to your subscriber's wishes

Technically, all that's required to stop the process is to remove the wallet pointer in the metatag.

In the frontend ./store/monetization/actions.ts:

async stopMonetizationStream({ commit }) {
    await commit("setIsStreaming", false)
    await commit("setCoilPointer", "")
    await commit(
      "main/addNotification",
      {
        content: "You have stopped web monetization.",
        color: "success",
      },
      { root: true }
    )
}
Enter fullscreen mode Exit fullscreen mode

Cascading back up the code, you call this.setMonetizationTag() which removes the metatag pointer.

From the frontend component ./components/monetization/MenuToggle.vue

if (monetizationTag && !this.coilPointer) {
  monetizationTag.remove()
}
Enter fullscreen mode Exit fullscreen mode

Revoking is slightly more convoluted. If a subscriber revokes, you need their authentication_token to perform the action. However, their token may no longer be valid, so you may need to first refresh their token and only then revoke your access to their keys.

In the frontend ./store/monetization/actions.ts:

async revokeMonetization({ commit, dispatch, state }) {
    await dispatch("stopMonetizationStream")
    if (state.coilToken) {
      await coil.revokeUserResource(state.coilToken)
      await commit("setCoilToken", "")
    } else if (getCoilRefresh()) {
      // Counterintuitively, we need a new user_token to request the revoke
      try {
        const response: AxiosResponse = await coil.getUserRefresh(
          getCoilRefresh() as string
        )
        const data: ICoilUserTokenResponse = response.data
        if (
          data &&
          Object.keys(data).length !== 0 &&
          data.constructor === Object
        )
          await coil.revokeUserResource(data.access_token)
      } catch (error) {
        await dispatch("checkCoilError", error)
      }
    }
    if (getCoilRefresh()) removeCoilRefresh()
    await commit(
      "main/addNotification",
      {
        content: "You have successfully revoked web monetization.",
        color: "success",
      },
      { root: true }
    )
}
Enter fullscreen mode Exit fullscreen mode

With the frontend api request in ./api/coil.ts:

async revokeUserResource(token: string) {
    const data: ICoilUserTokenRevokeRequest = {
      token,
    }
    return await axios.post(
      `${process.env.apiUrl}/api/v1/coil/proxy/oauth/token/revocation`,
      data,
      requestHeaders()
    )
}
Enter fullscreen mode Exit fullscreen mode

There is no response to a revoke request. However, remember we adopted an ethical design approach. If our subscriber has deliberately cleared their cookies, we no longer have access to their refresh_token anyway, and so no longer have access to their account.


And that's it (so far). There's more we can do ... split payments (proportionally specifying which wallet pointer is monetized), opening up sections of your app to streaming subscribers, etc. All of that requires a rock-solid payment process, and I hope this guide helps save you at least some time and frustration in achieving that.

Please let me know in the comments what you think, what I missed, and what can be fixed / improved.


References

For both this guide, and for those of you wishing to go further and add things like scheduled token refresh, proportional payments, etc.

Top comments (3)

Collapse
 
radhyr profile image
Radhy

There's so much information here Gavit. Nicely done 👍👍👍

Doing that in a Node app, though, will instantly crash your app during transitions since this is exactly the sort of state that Node will preserve. That means initialising the coilMonetizationPolyfill needs to look like this on every page:

This seems classic problems for frameworks that combine client-side hydration and server-side rendering, like Nuxt, Next, Sapper/SvelteKit. They run the code twice both in backend and frontend and since backend doesn't have document object thus the crash - which is why the solution is skipping the server-side by checking if process.client is truthy. I think traditional Node app that uses SSR without client-side rehydration (or detaching client-side framework from the server-side code) should be fine.

Collapse
 
chrislarry profile image
Chris Lawrence

This is an amazing deep dive! Have you posted this in other developer forums?

Collapse
 
turukawa profile image
Gavin Chait

No, but happy to see it redirected. I'm hoping people find it the old-fashioned way ...