diff --git a/services/poeditor/poeditor.service.js b/services/poeditor/poeditor.service.js
new file mode 100644
index 0000000000..e5bc71d794
--- /dev/null
+++ b/services/poeditor/poeditor.service.js
@@ -0,0 +1,113 @@
+'use strict'
+
+const Joi = require('@hapi/joi')
+const { nonNegativeInteger } = require('../validators')
+const { coveragePercentage } = require('../color-formatters')
+const { BaseJsonService, InvalidResponse } = require('..')
+
+const documentation = `
+
+ You must specify the read-only API token from the POEditor account to which the project belongs.
+
+
+ As per the POEditor API documentation,
+ all requests to the API must contain the parameter api_token. You can get a read-only key from your POEditor account.
+ You'll find it in My Account > API Access.
+
+`
+
+const schema = Joi.object({
+ response: Joi.object({
+ code: nonNegativeInteger,
+ message: Joi.string().required(),
+ }).required(),
+ result: Joi.object({
+ languages: Joi.array()
+ .items({
+ name: Joi.string().required(),
+ code: Joi.string().required(),
+ percentage: Joi.number()
+ .min(0)
+ .max(100)
+ .required(),
+ })
+ .required(),
+ }),
+}).required()
+
+const queryParamSchema = Joi.object({
+ token: Joi.string().required(),
+}).required()
+
+module.exports = class POEditor extends BaseJsonService {
+ static get category() {
+ return 'other'
+ }
+
+ static get route() {
+ return {
+ base: 'poeditor',
+ pattern: 'progress/:projectId/:languageCode',
+ queryParamSchema,
+ }
+ }
+
+ static get examples() {
+ return [
+ {
+ title: 'POEditor',
+ namedParams: { projectId: '323337', languageCode: 'fr' },
+ queryParams: { token: 'abc123def456' },
+ staticPreview: this.render({
+ code: 200,
+ message: 'OK',
+ language: { percentage: 93, code: 'fr', name: 'French' },
+ }),
+ keywords: ['l10n'],
+ documentation,
+ },
+ ]
+ }
+
+ static render({ code, message, language }) {
+ if (code !== 200) {
+ throw new InvalidResponse({ prettyMessage: message })
+ }
+
+ if (language === undefined) {
+ throw new InvalidResponse({ prettyMessage: 'Language not in project' })
+ }
+
+ return {
+ label: language.name,
+ message: `${language.percentage.toFixed(0)}%`,
+ color: coveragePercentage(language.percentage),
+ }
+ }
+
+ async fetch({ projectId, token }) {
+ return this._requestJson({
+ schema,
+ url: 'https://api.poeditor.com/v2/languages/list',
+ options: {
+ method: 'POST',
+ form: {
+ api_token: token,
+ id: projectId,
+ },
+ },
+ })
+ }
+
+ async handle({ projectId, languageCode }, { token }) {
+ const {
+ response: { code, message },
+ result: { languages } = { languages: [] },
+ } = await this.fetch({ projectId, token })
+ return this.constructor.render({
+ code,
+ message,
+ language: languages.find(lang => lang.code === languageCode),
+ })
+ }
+}
diff --git a/services/poeditor/poeditor.tester.js b/services/poeditor/poeditor.tester.js
new file mode 100644
index 0000000000..78db3b1657
--- /dev/null
+++ b/services/poeditor/poeditor.tester.js
@@ -0,0 +1,96 @@
+'use strict'
+
+const { isIntegerPercentage } = require('../test-validators')
+const t = (module.exports = require('../tester').createServiceTester())
+
+t.create('gets POEditor progress online')
+ .get('/progress/323337/de.json?token=7a666b44c0985d16a7b59748f488275c')
+ .expectBadge({
+ label: 'German',
+ message: isIntegerPercentage,
+ })
+
+t.create('gets POEditor progress online')
+ .get('/progress/1/zh.json?token=7a666b44c0985d16a7b59748f488275c')
+ .expectBadge({
+ label: 'other',
+ message: "You don't have permission to access this resource",
+ })
+
+// https:/.com/docs/api#languages_list_response
+const apiResponse = {
+ response: {
+ status: 'success',
+ code: '200',
+ message: 'OK',
+ },
+ result: {
+ languages: [
+ {
+ name: 'English',
+ code: 'en',
+ translations: 13,
+ percentage: 12.5,
+ updated: '2015-05-04T14:21:41+0000',
+ },
+ {
+ name: 'French',
+ code: 'fr',
+ translations: 70,
+ percentage: 68.75,
+ updated: '2015-04-30T08:59:34+0000',
+ },
+ ],
+ },
+}
+
+t.create('gets mock POEditor progress')
+ .get('/progress/1234/fr.json?token=abc123def456')
+ .intercept(nock =>
+ nock('https://api.poeditor.com')
+ .post('/v2/languages/list', {
+ id: '1234',
+ api_token: 'abc123def456',
+ })
+ .reply(200, apiResponse)
+ )
+ .expectBadge({
+ label: 'French',
+ message: '69%',
+ })
+
+t.create('handles requests for missing languages')
+ .get('/progress/1234/zh.json?token=abc123def456')
+ .intercept(nock =>
+ nock('https://api.poeditor.com')
+ .post('/v2/languages/list', {
+ id: '1234',
+ api_token: 'abc123def456',
+ })
+ .reply(200, apiResponse)
+ )
+ .expectBadge({
+ label: 'other',
+ message: 'Language not in project',
+ })
+
+t.create('handles requests for wrong keys')
+ .get('/progress/1234/fr.json?token=abc123def456')
+ .intercept(nock =>
+ nock('https://api.poeditor.com')
+ .post('/v2/languages/list', {
+ id: '1234',
+ api_token: 'abc123def456',
+ })
+ .reply(200, {
+ response: {
+ status: 'fail',
+ code: '403',
+ message: "You don't have permission to access this resource",
+ },
+ })
+ )
+ .expectBadge({
+ label: 'other',
+ message: "You don't have permission to access this resource",
+ })