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:
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:
- 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
2. They decide to authorise your app to receive payments and click on a link you generate
3. They are referred to Coil where they must approve authentication.
4. Coil refers the subscriber back to a specific receiving page where your app needs to process the token it receives and authenticate.
5. Your app updates the subscriber and invites them to begin streaming.
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
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.
- Follow Coil's documentation to register your developer account, and email
devs@coil.com
, -
It is normal to wait a week or more for them to respond, when they do, you will be able to register apps and request
authentication tokens
,https://coil.com/oauth_register
- Client app name: qwyre-local
- Redirect URIs (comma-separated): https://localhost:3000, https://localhost:3000/support-us
- Logo URI: https://localhost:3000/qwyre-logo.svg
Make a note of the app registration details as they are not listed anywhere afterwards, and you must have them exact for the next step.
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 isroot
.
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"
}'
GOTCHA:
tos_uri
andpolicy_uri
must behttps
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",
}
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'))
Then use that string as your
client_secret
.
2. Embed the OAuth Web Monetization (OWM) script in your app
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>
You'll notice three important components:
- The
meta
tag with the destination wallet for streaming payments, - A
script
tag withdefer
import of the OWM script, - And an
if else
statement setting up thediv
for interacting withdocument.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" }],
}
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>
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
At the end of this process you will have:
- Set up TypeScript interfaces for
requests
andresponses
,- Set up all API request code,
- Set up a proxy for queries to Coil,
- Store the
refresh_token
as a cookie, andaccess_token
andsub
in thestore
,- [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
}
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
orlogin
page, the reality is onlylogin
works. Note this for theprompt
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
}
}
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 thestate
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)
})
}
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)
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>
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
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)
}
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)
}
}
}
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) ...
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)
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()
)
}
This is a post
and you pass your app's authentication keys to Coil in the header:
requestHeaders()
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}`,
},
}
}
GOTCHA: Note again that you're escaping your two keys,
client_id
andclient_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 convertedclient_secret
to base64, then generated a new base64 string on the resultbtoa(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"
}
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)
}
}
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)
)
}
GOTCHA: you are using both a new API endpoint for this request, as well as a new header.
https://api.coil.com
, and thisheader
:
function requestUserHeaders(accessToken: string) {
return {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${accessToken}`,
},
}
}
Where
accessToken
is theaccess_token
you received fromgetUserToken
.In addition, your request to
getUserInfo
is aget
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"
}
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 oneGET
. The second is that not all requests are to the same URI. It switches fromhttps://coil.com
tohttps://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))
That's all you need on the server, but let me give you a little context and explanation.
@router.post("/proxy/{path:path}")
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
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)
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
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)
}
}
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)
)
}
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 apost
. It also has nodata
to send in post, so ensure you include an empty object:{}
. It also useshttps://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"
}
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>
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) {}
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()
}
}
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)
}
And when they tell you to stop, you remove it:
if (monetizationTag && !this.coilPointer) {
monetizationTag.remove()
}
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()
}
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
}
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 }
)
}
}
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 }
)
}
}
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()
)
}
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 }
)
}
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()
}
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 }
)
}
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()
)
}
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)
There's so much information here Gavit. Nicely done 👍👍👍
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 ifprocess.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.This is an amazing deep dive! Have you posted this in other developer forums?
No, but happy to see it redirected. I'm hoping people find it the old-fashioned way ...